Add hot reload

This commit is contained in:
Andras Schmelczer 2023-04-15 14:47:02 +01:00
commit 9cf8f73e18
No known key found for this signature in database
GPG key ID: FC8F2C3D3D1A718C
17 changed files with 20009 additions and 2656 deletions

1
.eslintignore Normal file
View file

@ -0,0 +1 @@
**/*.js

29
.eslintrc.json Normal file
View file

@ -0,0 +1,29 @@
{
"root": true,
"env": {
"browser": true,
"es2020": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 11,
"sourceType": "module"
},
"plugins": ["unused-imports", "@typescript-eslint", "prettier"],
"rules": {
"prettier/prettier": "error",
"no-unused-vars": "off",
"unused-imports/no-unused-imports-ts": "error",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/ban-ts-comment": "off"
}
}

2
.gitignore vendored
View file

@ -41,5 +41,3 @@ temp
*.js *.js
*.map *.map
!webpack.* !webpack.*
dist

View file

@ -1,6 +1,10 @@
{ {
"trailingComma": "none", "trailingComma": "es5",
"tabWidth": 4, "printWidth": 90,
"semi": true, "tabWidth": 2,
"singleQuote": true "singleQuote": true,
} "endOfLine": "lf",
"importOrder": ["^[./]", ".*", ".scss$"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true
}

View file

@ -10,46 +10,9 @@ A WebGPU repo you can use to get started with your own renderer.
## Setup ## Setup
First install:
- [Git](https://git-scm.com/)
- [Node.js](https://nodejs.org/en/)
- A Text Editor such as [Visual Studio Code](https://code.visualstudio.com/).
Then type the following in any terminal your such as [VS Code's Integrated Terminal](https://code.visualstudio.com/docs/editor/integrated-terminal).
```bash
# 🐑 Clone the repo
git clone https://github.com/alaingalvan/webgpu-seed
# 💿 go inside the folder
cd webgpu-seed
# 🔨 Start installing dependencies, building, and running at localhost:8080
npm start
```
> Refer to [this blog post on designing web libraries and apps](https://alain.xyz/blog/designing-a-web-app) for more details on Node.js, packages, etc. > Refer to [this blog post on designing web libraries and apps](https://alain.xyz/blog/designing-a-web-app) for more details on Node.js, packages, etc.
## Project Layout
As your project becomes more complex, you'll want to separate files and organize your application to something more akin to a game or renderer, check out this post on [game engine architecture](https://alain.xyz/blog/game-engine-architecture) and this one on [real time renderer architecture](https://alain.xyz/blog/realtime-renderer-architectures) for more details. As your project becomes more complex, you'll want to separate files and organize your application to something more akin to a game or renderer, check out this post on [game engine architecture](https://alain.xyz/blog/game-engine-architecture) and this one on [real time renderer architecture](https://alain.xyz/blog/realtime-renderer-architectures) for more details.
```bash
├─ 📂 node_modules/ # 👶 Dependencies
│ ├─ 📁 gl-matrix # Linear Algebra
│ └─ 📁 ... # 🕚 Other Dependencies (TypeScript, Webpack, etc.)
├─ 📂 src/ # 🌟 Source Files
│ ├─ 📄 index.html # 📇 Main HTML file
│ └─ 📄 renderer.ts # 🔺 Triangle Renderer
├─ 📄 .gitignore # 👁️ Ignore certain files in git repo
├─ 📄 package.json # 📦 Node Package File
├─ 📄 license.md # ⚖️ Your License (Unlicense)
└─ 📃readme.md # 📖 Read Me!
```
[license-img]: https://img.shields.io/:license-unlicense-blue.svg?style=flat-square [license-img]: https://img.shields.io/:license-unlicense-blue.svg?style=flat-square
[license-url]: https://unlicense.org/ [license-url]: https://unlicense.org/

15
definitions.d.ts vendored
View file

@ -1 +1,14 @@
declare module '*.wgsl'; declare module '*.wgsl' {
const content: string;
export default content;
}
declare module '*.svg' {
const content: string;
export default content;
}
declare module '*.html' {
const content: string;
export default content;
}

2
dist/index.html vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="UTF-8" />
<title>WebGPU Hello Triangle</title>
<style>
html,
body {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
background: #000;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<canvas id="gfx"></canvas>
<script type="text/javascript" src="dist/main.js"></script>
</body>
</html>

18815
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,9 +4,10 @@
"description": "🔺 A simple hello triangle example introducing WebGPU.", "description": "🔺 A simple hello triangle example introducing WebGPU.",
"main": "dist/main.js", "main": "dist/main.js",
"scripts": { "scripts": {
"start": "npm i && npm run build && npm run dev", "start": "webpack serve --open --mode development",
"dev": "http-server", "lint": "eslint --fix \"src/**/*.ts\" && prettier --write \"src/**/*.(ts|scss|json|html)\"",
"build": "cross-env NODE_ENV=production ts-node webpack.ts" "build": "webpack --mode production",
"update": "ncu"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -27,15 +28,32 @@
}, },
"homepage": "https://github.com/alaingalvan/webgpu-seed#readme", "homepage": "https://github.com/alaingalvan/webgpu-seed#readme",
"devDependencies": { "devDependencies": {
"@types/node": "^18.11.x", "@webgpu/types": "^0.1.30",
"@webgpu/types": "^0.1.26", "@trivago/prettier-plugin-sort-imports": "^3.3.0",
"clean-webpack-plugin": "^4.0.x", "@typescript-eslint/eslint-plugin": "^5.38.0",
"cross-env": "^7.0.x", "css-loader": "^6.7.1",
"http-server": "^14.1.x", "eslint": "^8.23.1",
"ts-loader": "^9.4.x", "eslint-config-prettier": "^8.5.0",
"ts-node": "^10.9.x", "eslint-plugin-prettier": "^4.2.1",
"typescript": "^4.9.x", "eslint-plugin-unused-imports": "^2.0.0",
"webpack": "^5.75.x" "html-webpack-plugin": "^5.5.0",
"inline-source-webpack-plugin": "^2.0.1",
"mini-css-extract-plugin": "^2.6.1",
"npm-check-updates": "^16.3.2",
"prettier": "^2.7.1",
"resolve-url-loader": "^5.0.0",
"responsive-loader": "^3.1.1",
"sass": "^1.55.0",
"sass-loader": "^13.0.2",
"sharp": "^0.31.0",
"string-replace-loader": "^3.1.0",
"svg-inline-loader": "^0.8.2",
"terser-webpack-plugin": "^5.3.6",
"ts-loader": "^9.4.1",
"typescript": "^4.8.3",
"webpack": "^5.74.0",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.11.1"
}, },
"dependencies": { "dependencies": {
"gl-matrix": "^3.4.3" "gl-matrix": "^3.4.3"

38
src/index.html Normal file
View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta property="og:title" content="WebGPU | Andras Schmelczer" />
<meta property="og:description" content="Discover my projects." />
<meta property="og:url" content="https://schmelczer.dev" />
<meta property="og:image:width" content="1920" />
<meta property="og:image:height" content="1920" />
<meta property="og:image" content="https://schmelczer.dev/og-image.jpg" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" href="favicon.ico" type="image/x-icon" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta
name="description"
content="I'm Andras Schmelczer, and this is my portfolio. Discover some of my projects. I'm passionate about solving challenging problems and designing large-scale systems, especially in the context of machine learning."
/>
<meta
name="viewport"
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
<meta name="theme-color" content="#b7455e" />
<title>WebGPU | Andras Schmelczer</title>
<link inline inline-asset="index.css" inline-asset-delete />
</head>
<body>
<noscript>JavaScript is required for this website.</noscript>
<canvas></canvas>
<script inline inline-asset="index.js" inline-asset-delete></script>
</body>
</html>

15
src/index.scss Normal file
View file

@ -0,0 +1,15 @@
html,
body {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
background: #000;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}

View file

@ -1,6 +1,7 @@
import './index.scss';
import Renderer from './renderer'; import Renderer from './renderer';
const canvas = document.getElementById('gfx') as HTMLCanvasElement; const canvas = document.querySelector('canvas') as HTMLCanvasElement;
canvas.width = canvas.height = 640; canvas.width = canvas.height = 640;
const renderer = new Renderer(canvas); const renderer = new Renderer(canvas);
renderer.start(); renderer.start();

View file

@ -1,20 +1,8 @@
import vertShaderCode from './shaders/triangle.vert.wgsl';
import fragShaderCode from './shaders/triangle.frag.wgsl'; import fragShaderCode from './shaders/triangle.frag.wgsl';
import vertShaderCode from './shaders/triangle.vert.wgsl';
const positions = new Float32Array([ const positions = new Float32Array([1.0, -1.0, 0.0, -1.0, -1.0, 0.0, 0.0, 1.0, 0.0]);
1.0, -1.0, 0.0, -1.0, -1.0, 0.0, 0.0, 1.0, 0.0 const colors = new Float32Array([1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0]);
]);
const colors = new Float32Array([
1.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
1.0
]);
const indices = new Uint16Array([0, 1, 2]); const indices = new Uint16Array([0, 1, 2]);
@ -72,17 +60,14 @@ export default class Renderer {
} }
async initializeResources() { async initializeResources() {
const createBuffer = ( const createBuffer = (arr: Float32Array | Uint16Array, usage: number) => {
arr: Float32Array | Uint16Array,
usage: number
) => {
// 📏 Align to 4 bytes (thanks @chrimsonite) // 📏 Align to 4 bytes (thanks @chrimsonite)
let desc = { const desc = {
size: (arr.byteLength + 3) & ~3, size: (arr.byteLength + 3) & ~3,
usage, usage,
mappedAtCreation: true mappedAtCreation: true,
}; };
let buffer = this.device.createBuffer(desc); const buffer = this.device.createBuffer(desc);
const writeArray = const writeArray =
arr instanceof Uint16Array arr instanceof Uint16Array
? new Uint16Array(buffer.getMappedRange()) ? new Uint16Array(buffer.getMappedRange())
@ -97,40 +82,40 @@ export default class Renderer {
this.indexBuffer = createBuffer(indices, GPUBufferUsage.INDEX); this.indexBuffer = createBuffer(indices, GPUBufferUsage.INDEX);
const vsmDesc = { const vsmDesc = {
code: vertShaderCode code: vertShaderCode,
}; };
this.vertModule = this.device.createShaderModule(vsmDesc); this.vertModule = this.device.createShaderModule(vsmDesc);
const fsmDesc = { const fsmDesc = {
code: fragShaderCode code: fragShaderCode,
}; };
this.fragModule = this.device.createShaderModule(fsmDesc); this.fragModule = this.device.createShaderModule(fsmDesc);
const positionAttribDesc: GPUVertexAttribute = { const positionAttribDesc: GPUVertexAttribute = {
shaderLocation: 0, // [[location(0)]] shaderLocation: 0, // [[location(0)]]
offset: 0, offset: 0,
format: 'float32x3' format: 'float32x3',
}; };
const colorAttribDesc: GPUVertexAttribute = { const colorAttribDesc: GPUVertexAttribute = {
shaderLocation: 1, // [[location(1)]] shaderLocation: 1, // [[location(1)]]
offset: 0, offset: 0,
format: 'float32x3' format: 'float32x3',
}; };
const positionBufferDesc: GPUVertexBufferLayout = { const positionBufferDesc: GPUVertexBufferLayout = {
attributes: [positionAttribDesc], attributes: [positionAttribDesc],
arrayStride: 4 * 3, // sizeof(float) * 3 arrayStride: 4 * 3, // sizeof(float) * 3
stepMode: 'vertex' stepMode: 'vertex',
}; };
const colorBufferDesc: GPUVertexBufferLayout = { const colorBufferDesc: GPUVertexBufferLayout = {
attributes: [colorAttribDesc], attributes: [colorAttribDesc],
arrayStride: 4 * 3, // sizeof(float) * 3 arrayStride: 4 * 3, // sizeof(float) * 3
stepMode: 'vertex' stepMode: 'vertex',
}; };
const depthStencil: GPUDepthStencilState = { const depthStencil: GPUDepthStencilState = {
depthWriteEnabled: true, depthWriteEnabled: true,
depthCompare: 'less', depthCompare: 'less',
format: 'depth24plus-stencil8' format: 'depth24plus-stencil8',
}; };
const pipelineLayoutDesc = { bindGroupLayouts: [] }; const pipelineLayoutDesc = { bindGroupLayouts: [] };
@ -139,23 +124,23 @@ export default class Renderer {
const vertex: GPUVertexState = { const vertex: GPUVertexState = {
module: this.vertModule, module: this.vertModule,
entryPoint: 'main', entryPoint: 'main',
buffers: [positionBufferDesc, colorBufferDesc] buffers: [positionBufferDesc, colorBufferDesc],
}; };
const colorState: GPUColorTargetState = { const colorState: GPUColorTargetState = {
format: 'bgra8unorm' format: 'bgra8unorm',
}; };
const fragment: GPUFragmentState = { const fragment: GPUFragmentState = {
module: this.fragModule, module: this.fragModule,
entryPoint: 'main', entryPoint: 'main',
targets: [colorState] targets: [colorState],
}; };
const primitive: GPUPrimitiveState = { const primitive: GPUPrimitiveState = {
frontFace: 'cw', frontFace: 'cw',
cullMode: 'none', cullMode: 'none',
topology: 'triangle-list' topology: 'triangle-list',
}; };
const pipelineDesc: GPURenderPipelineDescriptor = { const pipelineDesc: GPURenderPipelineDescriptor = {
@ -165,21 +150,19 @@ export default class Renderer {
fragment, fragment,
primitive, primitive,
depthStencil depthStencil,
}; };
this.pipeline = this.device.createRenderPipeline(pipelineDesc); this.pipeline = this.device.createRenderPipeline(pipelineDesc);
} }
resizeBackings() { resizeBackings() {
if (!this.context) { if (!this.context) {
this.context = this.canvas.getContext('webgpu'); this.context = this.canvas.getContext('webgpu') as any;
const canvasConfig: GPUCanvasConfiguration = { const canvasConfig: GPUCanvasConfiguration = {
device: this.device, device: this.device,
format: 'bgra8unorm', format: 'bgra8unorm',
usage: usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
GPUTextureUsage.RENDER_ATTACHMENT | alphaMode: 'opaque',
GPUTextureUsage.COPY_SRC,
alphaMode: 'opaque'
}; };
this.context.configure(canvasConfig); this.context.configure(canvasConfig);
} }
@ -188,7 +171,7 @@ export default class Renderer {
size: [this.canvas.width, this.canvas.height, 1], size: [this.canvas.width, this.canvas.height, 1],
dimension: '2d', dimension: '2d',
format: 'depth24plus-stencil8', format: 'depth24plus-stencil8',
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
}; };
this.depthTexture = this.device.createTexture(depthTextureDesc); this.depthTexture = this.device.createTexture(depthTextureDesc);
@ -196,11 +179,11 @@ export default class Renderer {
} }
encodeCommands() { encodeCommands() {
let colorAttachment: GPURenderPassColorAttachment = { const colorAttachment: GPURenderPassColorAttachment = {
view: this.colorTextureView, view: this.colorTextureView,
clearValue: { r: 0, g: 0, b: 0, a: 1 }, clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear', loadOp: 'clear',
storeOp: 'store' storeOp: 'store',
}; };
const depthAttachment: GPURenderPassDepthStencilAttachment = { const depthAttachment: GPURenderPassDepthStencilAttachment = {
@ -210,33 +193,20 @@ export default class Renderer {
depthStoreOp: 'store', depthStoreOp: 'store',
stencilClearValue: 0, stencilClearValue: 0,
stencilLoadOp: 'clear', stencilLoadOp: 'clear',
stencilStoreOp: 'store' stencilStoreOp: 'store',
}; };
const renderPassDesc: GPURenderPassDescriptor = { const renderPassDesc: GPURenderPassDescriptor = {
colorAttachments: [colorAttachment], colorAttachments: [colorAttachment],
depthStencilAttachment: depthAttachment depthStencilAttachment: depthAttachment,
}; };
this.commandEncoder = this.device.createCommandEncoder(); this.commandEncoder = this.device.createCommandEncoder();
// 🖌️ Encode drawing commands
this.passEncoder = this.commandEncoder.beginRenderPass(renderPassDesc); this.passEncoder = this.commandEncoder.beginRenderPass(renderPassDesc);
this.passEncoder.setPipeline(this.pipeline); this.passEncoder.setPipeline(this.pipeline);
this.passEncoder.setViewport( this.passEncoder.setViewport(0, 0, this.canvas.width, this.canvas.height, 0, 1);
0, this.passEncoder.setScissorRect(0, 0, this.canvas.width, this.canvas.height);
0,
this.canvas.width,
this.canvas.height,
0,
1
);
this.passEncoder.setScissorRect(
0,
0,
this.canvas.width,
this.canvas.height
);
this.passEncoder.setVertexBuffer(0, this.positionBuffer); this.passEncoder.setVertexBuffer(0, this.positionBuffer);
this.passEncoder.setVertexBuffer(1, this.colorBuffer); this.passEncoder.setVertexBuffer(1, this.colorBuffer);
this.passEncoder.setIndexBuffer(this.indexBuffer, 'uint16'); this.passEncoder.setIndexBuffer(this.indexBuffer, 'uint16');

View file

@ -21,17 +21,13 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"noEmitHelpers": false, "noEmitHelpers": false,
"sourceMap": false, "sourceMap": true,
"strictNullChecks": false, "strictNullChecks": false,
"jsx": "react", "jsx": "react",
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true
}, },
"include": [ "include": ["definitions.d.ts", "src/**/*", "node_modules/@webgpu/types/**/*"],
"definitions.d.ts",
"src/**/*",
"node_modules/@webgpu/types/**/*"
],
"compileOnSave": false, "compileOnSave": false,
"buildOnSave": false "buildOnSave": false
} }

90
webpack.config.js Normal file
View file

@ -0,0 +1,90 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const InlineSourceWebpackPlugin = require('inline-source-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = (env, argv) => ({
devtool: argv.mode === 'development' ? 'inline-source-map' : false,
entry: {
index: './src/index.ts',
},
watchOptions: {
ignored: '**/node_modules',
},
optimization: {
minimizer: [
new TerserPlugin({
terserOptions: {
module: true,
},
}),
],
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
}),
new MiniCssExtractPlugin(),
argv.mode === 'production'
? new InlineSourceWebpackPlugin({
compress: true,
})
: null,
new (require('webpack').DefinePlugin)({
__CURRENT_DATE__: Date.now(),
}),
].filter(Boolean),
module: {
rules: [
{
test: /\.svg$/i,
use: 'svg-inline-loader',
},
{
test: /\.wgsl/,
type: 'asset/source',
generator: {
filename: '[name][ext]',
},
},
{
test: /\/no-change\//i,
type: 'asset/resource',
generator: {
filename: '[name][ext]',
},
},
{
test: /\.scss$/i,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'resolve-url-loader',
{
loader: 'sass-loader',
options: {
sourceMap: true, // required by resolve-url-loader
},
},
],
},
{
test: /\.ts$/,
use: 'ts-loader',
},
],
},
resolve: {
extensions: [
'.ts',
'.js', // required for development
],
},
output: {
clean: true,
filename: '[name].js',
path: path.resolve(__dirname, 'dist'),
publicPath: '',
},
});

View file

@ -1,112 +0,0 @@
import webpack from 'webpack';
import { CleanWebpackPlugin } from 'clean-webpack-plugin';
import path from 'path';
import { argv } from 'process';
let env = process.env['NODE_ENV'];
let isProduction =
(env && env.match(/production/)) ||
argv.reduce((prev, cur) => prev || cur === '--production', false);
let config: webpack.Configuration = {
context: path.join(__dirname, 'src'),
entry: {
app: './main.ts'
},
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist')
},
resolve: {
extensions: ['.ts', '.tsx', 'js'],
modules: [path.resolve(__dirname, 'src'), 'node_modules']
},
module: {
rules: [
{
test: /\.ts/,
exclude: /node_modules/,
loader: 'ts-loader',
options: {
transpileOnly: true,
compilerOptions: {
isolatedModules: true
}
}
},
{
test: /\.wgsl/,
type: 'asset/source'
}
]
},
node: false,
plugins: [
new CleanWebpackPlugin(),
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify(
isProduction ? 'production' : 'development'
)
}
})
],
optimization: {
minimize: isProduction ? true : false
}
};
/**
* Start Build
*/
const compiler: webpack.Compiler = webpack(config);
if (!argv.reduce((prev, cur) => prev || cur === '--watch', false)) {
compiler.run((err, stats) => {
if (err) return console.error(err);
if (stats.hasErrors()) {
let statsJson = stats.toJson();
console.log(
'❌' + ' · Error · ' + 'webgpu-seed failed to compile:'
);
for (let error of statsJson.errors) {
console.log(error.message);
}
return;
}
console.log(
'✔️️' +
' · Success · ' +
'webgpu-seed' +
(isProduction ? ' (production) ' : ' (development) ') +
'built in ' +
(+stats.endTime - +stats.startTime + ' ms.')
);
});
} else {
compiler.watch({}, (err, stats) => {
if (err) return console.error(err);
if (stats.hasErrors()) {
let statsJson = stats.toJson();
console.log(
'❌' + ' · Error · ' + 'webgpu-seed failed to compile:'
);
for (let error of statsJson.errors) {
console.log(error.message);
}
console.log('\n👀 · Watching for changes... · \n');
return;
}
console.log(
'✔️️' +
' · Success · ' +
'webgpu-seed' +
(isProduction ? ' (production) ' : ' (development) ') +
'built in ' +
(+stats.endTime - +stats.startTime + ' ms.') +
'\n👀 · Watching for changes... · \n'
);
});
}