@es3d/es3d 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +86 -3
- package/docs/REFERENCE.md +581 -0
- package/docs/SPEC.md +417 -0
- package/package.json +50 -4
- package/scripts/es3d-cli.mjs +377 -0
- package/src/cache/geometry-store.js +392 -0
- package/src/cache/mesh-bundle.js +123 -0
- package/src/cache/sha256.js +73 -0
- package/src/format/ast.js +238 -0
- package/src/format/colors.js +93 -0
- package/src/format/compact-parser.js +171 -0
- package/src/format/compact-tokenizer.js +165 -0
- package/src/format/errors.js +247 -0
- package/src/format/json.js +55 -0
- package/src/format/limits.js +14 -0
- package/src/format/parse.js +92 -0
- package/src/format/schema.js +617 -0
- package/src/format/serializer.js +333 -0
- package/src/format/validator.js +330 -0
- package/src/format/value-error.js +12 -0
- package/src/format/value-format.js +314 -0
- package/src/format/values.js +459 -0
- package/src/format/xml-parser.js +136 -0
- package/src/format/xml-tokenizer.js +237 -0
- package/src/geometry/contour.js +86 -0
- package/src/geometry/extrude.js +175 -0
- package/src/geometry/gear.js +85 -0
- package/src/geometry/geometry-cache.js +159 -0
- package/src/geometry/lod-error.js +178 -0
- package/src/geometry/mesh.js +458 -0
- package/src/geometry/primitives.js +299 -0
- package/src/geometry/registry.js +645 -0
- package/src/geometry/revolve.js +136 -0
- package/src/geometry/sweep.js +441 -0
- package/src/index.js +47 -0
- package/src/io/compression.js +67 -0
- package/src/io/convert.js +236 -0
- package/src/io/download.js +13 -0
- package/src/io/external-scene.js +127 -0
- package/src/io/file-loader.js +28 -0
- package/src/io/gltf.js +630 -0
- package/src/io/mesh-import.js +104 -0
- package/src/io/obj.js +352 -0
- package/src/io/step-brep.js +439 -0
- package/src/io/step-exact.js +1149 -0
- package/src/io/step.js +1418 -0
- package/src/io/stl.js +193 -0
- package/src/io/svg-import.js +787 -0
- package/src/material/materials.js +154 -0
- package/src/math/mat4.js +475 -0
- package/src/math/random.js +30 -0
- package/src/math/trig.js +65 -0
- package/src/math/vec3.js +81 -0
- package/src/optimize/optimizer.js +579 -0
- package/src/path/path-flatten.js +322 -0
- package/src/path/path-parser.js +332 -0
- package/src/path/shapes.js +129 -0
- package/src/path/triangulate.js +517 -0
- package/src/pipeline.js +74 -0
- package/src/render/camera.js +168 -0
- package/src/render/frame-builder.js +362 -0
- package/src/render/geometry-pool.js +149 -0
- package/src/render/lighting.js +149 -0
- package/src/render/orbit-controls.js +237 -0
- package/src/render/renderer.js +249 -0
- package/src/render/scroll-timeline.js +104 -0
- package/src/render/shaders/glsl.js +285 -0
- package/src/render/shaders/wgsl.js +252 -0
- package/src/render/stage.js +352 -0
- package/src/render/texture-cache.js +40 -0
- package/src/render/textures.js +223 -0
- package/src/render/webgl2/webgl2-backend.js +532 -0
- package/src/render/webgpu/webgpu-backend.js +480 -0
- package/src/scene/animation.js +171 -0
- package/src/scene/camera-constraints.js +117 -0
- package/src/scene/flatten.js +754 -0
- package/src/scene/picking.js +161 -0
- package/src/scene/scene-graph.js +320 -0
- package/src/transform/transform.js +211 -0
- package/src/wasm/kernels-binary.js +3 -0
- package/src/wasm/kernels.js +385 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Renderer facade: picks a GPU backend (WebGPU preferred, WebGL2 fallback)
|
|
2
|
+
// and runs the backend-independent parts of a frame (camera, culling, LOD,
|
|
3
|
+
// batching, uniform packing, incremental geometry upload).
|
|
4
|
+
//
|
|
5
|
+
// The facade is the only renderer API used by the application and the
|
|
6
|
+
// benchmark; backends never see the ES3D document, only a RenderScene.
|
|
7
|
+
|
|
8
|
+
import { GeometryPool } from './geometry-pool.js';
|
|
9
|
+
import { FrameBuilder } from './frame-builder.js';
|
|
10
|
+
import { TextureCache } from './texture-cache.js';
|
|
11
|
+
import { FRAME_FLOATS, packFrameUniforms } from './lighting.js';
|
|
12
|
+
import { Mesh } from '../geometry/mesh.js';
|
|
13
|
+
import { WebGL2Backend } from './webgl2/webgl2-backend.js';
|
|
14
|
+
import { WebGPUBackend } from './webgpu/webgpu-backend.js';
|
|
15
|
+
|
|
16
|
+
const now = () => performance.now();
|
|
17
|
+
|
|
18
|
+
function cubeEdges() {
|
|
19
|
+
const p = [];
|
|
20
|
+
for (let i = 0; i < 8; i++) p.push(i & 1 ? 0.5 : -0.5, i & 2 ? 0.5 : -0.5, i & 4 ? 0.5 : -0.5);
|
|
21
|
+
const e = [0, 1, 2, 3, 4, 5, 6, 7, 0, 2, 1, 3, 4, 6, 5, 7, 0, 4, 1, 5, 2, 6, 3, 7];
|
|
22
|
+
return new Mesh({ positions: Float32Array.from(p), indices: Uint32Array.from(e), kind: 'lines' });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const OVERLAY_COLORS = {
|
|
26
|
+
wireframe: [0.02, 0.03, 0.05, 0.55],
|
|
27
|
+
normals: [1.0, 0.82, 0.15, 0.95],
|
|
28
|
+
bbox: [0.35, 0.78, 1.0, 0.75],
|
|
29
|
+
selection: [1.0, 0.55, 0.12, 1.0],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// setScene keeps the packed geometry, so a new document that shares meshes
|
|
33
|
+
// with the last one (a colour change, an edit elsewhere in the file) uploads
|
|
34
|
+
// only what is new. Meshes no longer used stay packed until the pool passes
|
|
35
|
+
// this size; then it is repacked from what the scene draws.
|
|
36
|
+
const POOL_REPACK_BYTES = 32 << 20;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Tessellation budget (ms per frame) for render loops that draw again while
|
|
40
|
+
* stats.refining is non-zero: the first frame comes at once with coarser
|
|
41
|
+
* stand-ins, full detail a few frames later.
|
|
42
|
+
*/
|
|
43
|
+
export const PROGRESSIVE_BUDGET_MS = 4;
|
|
44
|
+
|
|
45
|
+
export class Renderer {
|
|
46
|
+
constructor(backend) {
|
|
47
|
+
this.backend = backend;
|
|
48
|
+
this.kind = backend.kind;
|
|
49
|
+
this.pool = new GeometryPool();
|
|
50
|
+
this.frames = new FrameBuilder();
|
|
51
|
+
this.textures = new TextureCache();
|
|
52
|
+
this.frameData = new Float32Array(FRAME_FLOATS);
|
|
53
|
+
this.options = {
|
|
54
|
+
wireframe: false,
|
|
55
|
+
normals: false,
|
|
56
|
+
bbox: false,
|
|
57
|
+
autoLod: true,
|
|
58
|
+
fixedLod: 0,
|
|
59
|
+
culling: true,
|
|
60
|
+
lodBias: 1,
|
|
61
|
+
// Same picture on every screen: the canvas resolution is capped at this
|
|
62
|
+
// many device pixels per CSS pixel, and automatic LOD budgets its error in
|
|
63
|
+
// CSS pixels, so a phone and a desktop choose the same meshes for the
|
|
64
|
+
// same view ('device' spends more triangles on high-density screens).
|
|
65
|
+
maxPixelRatio: 2,
|
|
66
|
+
lodPixels: 'css',
|
|
67
|
+
// Milliseconds of mesh generation per frame. Past it, levels that do not
|
|
68
|
+
// exist yet are drawn with a coarser one, and stats.refining counts them:
|
|
69
|
+
// render again while it is non-zero and full detail follows within a few
|
|
70
|
+
// frames. Infinity (the default) generates everything the frame needs.
|
|
71
|
+
tessellationBudget: Infinity,
|
|
72
|
+
};
|
|
73
|
+
this.selection = new Set();
|
|
74
|
+
this.scene = null;
|
|
75
|
+
this.meshOf = null;
|
|
76
|
+
this.cube = cubeEdges();
|
|
77
|
+
this.cubeRange = this.pool.meshRange(this.cube);
|
|
78
|
+
this.lastDraw = null;
|
|
79
|
+
this.lightInfo = { truncated: 0, usedDefaults: false };
|
|
80
|
+
this.stats = { cpuMs: 0, uploadBytes: 0, totalUploadBytes: 0, drawCalls: 0, triangles: 0, visible: 0, instances: 0 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get info() {
|
|
84
|
+
return this.backend.info;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What this device actually renders with, for checking that two devices
|
|
89
|
+
* show the same thing: backend, MSAA samples, texture filtering, limits,
|
|
90
|
+
* and whether the scene has more lights than are drawn.
|
|
91
|
+
*/
|
|
92
|
+
get caps() {
|
|
93
|
+
return { backend: this.kind, ...this.backend.caps, maxPixelRatio: this.options.maxPixelRatio, lodPixels: this.options.lodPixels, lightsDropped: this.lightInfo.truncated || 0 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Device pixels per CSS pixel actually used for a requested ratio. */
|
|
97
|
+
pixelRatio(dpr) {
|
|
98
|
+
return Math.min(dpr || 1, this.options.maxPixelRatio || Infinity);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Replace the scene. Geometry is packed lazily into the shared buffers.
|
|
103
|
+
* Meshes already packed are kept (pass keepGeometry: false for a cold start).
|
|
104
|
+
*/
|
|
105
|
+
setScene(scene, meshOf, { keepGeometry = true } = {}) {
|
|
106
|
+
this.scene = scene;
|
|
107
|
+
this.meshOf = meshOf;
|
|
108
|
+
if (!scene || !keepGeometry || this.pool.byteSize > POOL_REPACK_BYTES) {
|
|
109
|
+
this.pool.reset();
|
|
110
|
+
this.cubeRange = this.pool.meshRange(this.cube);
|
|
111
|
+
}
|
|
112
|
+
this.backend.releaseMaterials(new Set(scene ? scene.materials : []));
|
|
113
|
+
this.stats.totalUploadBytes = 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Render one frame.
|
|
118
|
+
* @param {import('./camera.js').Camera} camera
|
|
119
|
+
* @param {{width:number, height:number, dpr?:number, time?:number}} view CSS size of the canvas
|
|
120
|
+
*/
|
|
121
|
+
render(camera, view) {
|
|
122
|
+
const t0 = now();
|
|
123
|
+
const dpr = this.pixelRatio(view.dpr);
|
|
124
|
+
const w = Math.max(1, Math.round(view.width * dpr));
|
|
125
|
+
const h = Math.max(1, Math.round(view.height * dpr));
|
|
126
|
+
this.backend.resize(w, h);
|
|
127
|
+
const scene = this.scene;
|
|
128
|
+
const bounds = scene ? scene.bounds : { min: [-1, -1, -1], max: [1, 1, 1] };
|
|
129
|
+
camera.update(w / h, this.backend.zeroToOne, bounds);
|
|
130
|
+
const env = scene && scene.environment ? scene.environment : { background: [0.09, 0.1, 0.12, 1], sky: [0.8, 0.86, 0.95, 1], ground: [0.25, 0.22, 0.2, 1], intensity: 0.5, exposure: 1 };
|
|
131
|
+
let draw;
|
|
132
|
+
if (scene) {
|
|
133
|
+
draw = this.buildFrame(camera, view, h);
|
|
134
|
+
} else {
|
|
135
|
+
draw = { batches: [], overlays: { wireframe: [], normals: [], bbox: null, selection: null }, instanceData: new Float32Array(16), instanceCount: 0, stats: { instances: 0, visible: 0, culled: 0, batches: 0, drawCalls: 0, triangles: 0, lodHistogram: [0, 0, 0, 0], refining: 0 } };
|
|
136
|
+
}
|
|
137
|
+
const radius = Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) / 2 || 1;
|
|
138
|
+
this.lightInfo = packFrameUniforms(this.frameData, {
|
|
139
|
+
viewProj: camera.viewProj,
|
|
140
|
+
view: camera.view,
|
|
141
|
+
cameraPos: camera.position,
|
|
142
|
+
time: view.time || 0,
|
|
143
|
+
environment: env,
|
|
144
|
+
lights: scene ? scene.lights : [],
|
|
145
|
+
normalLength: radius * 0.025,
|
|
146
|
+
lineBias: 0.0015,
|
|
147
|
+
viewportHeight: h,
|
|
148
|
+
});
|
|
149
|
+
const uploaded = this.backend.syncPool(this.pool);
|
|
150
|
+
this.backend.render(draw, this.frameData, {
|
|
151
|
+
background: env.background,
|
|
152
|
+
textures: this.textures,
|
|
153
|
+
overlayColors: OVERLAY_COLORS,
|
|
154
|
+
cubeRange: this.cubeRange,
|
|
155
|
+
});
|
|
156
|
+
this.lastDraw = draw;
|
|
157
|
+
const s = this.stats;
|
|
158
|
+
s.cpuMs = now() - t0;
|
|
159
|
+
s.uploadBytes = uploaded + draw.instanceCount * 64;
|
|
160
|
+
s.totalUploadBytes += uploaded;
|
|
161
|
+
s.drawCalls = draw.stats.drawCalls;
|
|
162
|
+
s.triangles = draw.stats.triangles;
|
|
163
|
+
s.visible = draw.stats.visible;
|
|
164
|
+
s.instances = draw.stats.instances;
|
|
165
|
+
s.culled = draw.stats.culled;
|
|
166
|
+
s.batches = draw.stats.batches;
|
|
167
|
+
s.lodHistogram = draw.stats.lodHistogram;
|
|
168
|
+
s.refining = draw.stats.refining || 0;
|
|
169
|
+
s.geometryBytes = this.pool.byteSize;
|
|
170
|
+
s.instanceBytes = draw.instanceCount * 64;
|
|
171
|
+
s.textureBytes = this.backend.gpuBytes;
|
|
172
|
+
return draw;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
buildFrame(camera, view, deviceHeight, tessellationBudget = this.options.tessellationBudget) {
|
|
176
|
+
const lodHeight = this.options.lodPixels === 'device' ? deviceHeight : Math.max(1, Math.round(view.height));
|
|
177
|
+
return this.frames.build({
|
|
178
|
+
scene: this.scene,
|
|
179
|
+
camera,
|
|
180
|
+
pool: this.pool,
|
|
181
|
+
meshOf: this.meshOf,
|
|
182
|
+
viewportHeight: lodHeight,
|
|
183
|
+
options: { ...this.options, selection: this.selection, tessellationBudget },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Generate and pack (but do not draw) every mesh a view needs, so a later
|
|
189
|
+
* frame from that view does not stall on tessellation. The upload happens
|
|
190
|
+
* with the next render. Used to warm up a scroll-driven camera path.
|
|
191
|
+
* @param {import('./camera.js').Camera} camera not modified
|
|
192
|
+
* @param {{width:number, height:number, dpr?:number}} view
|
|
193
|
+
*/
|
|
194
|
+
prepare(camera, view) {
|
|
195
|
+
if (!this.scene) return;
|
|
196
|
+
const dpr = this.pixelRatio(view.dpr);
|
|
197
|
+
const w = Math.max(1, Math.round(view.width * dpr));
|
|
198
|
+
const h = Math.max(1, Math.round(view.height * dpr));
|
|
199
|
+
const cam = camera.clone();
|
|
200
|
+
cam.update(w / h, this.backend.zeroToOne, this.scene.bounds);
|
|
201
|
+
const keep = this.frames.last;
|
|
202
|
+
this.buildFrame(cam, view, h, Infinity);
|
|
203
|
+
this.frames.last = keep;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Wait for the GPU to finish all submitted work (benchmarks). */
|
|
207
|
+
finish() {
|
|
208
|
+
return this.backend.finish();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
dispose() {
|
|
212
|
+
this.textures.clear((g) => this.backend.destroyTexture(g));
|
|
213
|
+
this.backend.dispose();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Create a renderer for a canvas.
|
|
219
|
+
* @param {HTMLCanvasElement} canvas
|
|
220
|
+
* @param {{backend?: 'auto'|'webgpu'|'webgl2', present?: 'canvas'|'readback'}} [opts]
|
|
221
|
+
*/
|
|
222
|
+
export async function createRenderer(canvas, opts = {}) {
|
|
223
|
+
const want = opts.backend || 'auto';
|
|
224
|
+
const errors = [];
|
|
225
|
+
if ((want === 'auto' || want === 'webgpu') && typeof navigator !== 'undefined' && navigator.gpu) {
|
|
226
|
+
const b = new WebGPUBackend(canvas, { present: opts.present });
|
|
227
|
+
try {
|
|
228
|
+
await b.init();
|
|
229
|
+
return new Renderer(b);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
errors.push(e.message);
|
|
232
|
+
if (want === 'webgpu') throw e;
|
|
233
|
+
}
|
|
234
|
+
} else if (want === 'webgpu') {
|
|
235
|
+
throw new Error('WebGPU is not available in this browser.');
|
|
236
|
+
}
|
|
237
|
+
// A canvas that already has a WebGPU context cannot create a WebGL2 context.
|
|
238
|
+
let target = canvas;
|
|
239
|
+
if (errors.length && canvas.getContext && canvas.parentNode) {
|
|
240
|
+
target = canvas.cloneNode(false);
|
|
241
|
+
canvas.parentNode.replaceChild(target, canvas);
|
|
242
|
+
}
|
|
243
|
+
const b = new WebGL2Backend(target);
|
|
244
|
+
await b.init();
|
|
245
|
+
const r = new Renderer(b);
|
|
246
|
+
r.fallbackReason = errors.join('; ');
|
|
247
|
+
r.canvas = target;
|
|
248
|
+
return r;
|
|
249
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Page scroll -> animation progress for timeline="scroll" animations.
|
|
2
|
+
//
|
|
3
|
+
// Progress runs from 0 to 1 while a section of the page scrolls past. The
|
|
4
|
+
// usual layout is a tall section with a sticky canvas inside it:
|
|
5
|
+
//
|
|
6
|
+
// <section id="tour" style="height: 400vh">
|
|
7
|
+
// <div style="position: sticky; top: 0; height: 100vh"> canvas </div>
|
|
8
|
+
// <p data-es3d-stop>…</p> <p data-es3d-stop="0.5">…</p> …
|
|
9
|
+
// </section>
|
|
10
|
+
//
|
|
11
|
+
// Without stops, progress is 0 when the section's top reaches the top of the
|
|
12
|
+
// viewport and 1 when its bottom reaches the bottom. Elements inside it with
|
|
13
|
+
// data-es3d-stop pin progress values to page content instead: a stop is
|
|
14
|
+
// reached when the element's centre is at the viewport's centre (its value,
|
|
15
|
+
// or i / (n - 1) for the i-th of n stops when the attribute is empty), and
|
|
16
|
+
// progress is interpolated between stops. Give the camera's keyTimes the same
|
|
17
|
+
// values and each key view lines up with its paragraph.
|
|
18
|
+
//
|
|
19
|
+
// Scroll events only mark the timeline stale; update() reads layout at most
|
|
20
|
+
// once per frame. The value eases towards the scroll position so wheel steps
|
|
21
|
+
// do not jerk the camera. With reduced motion it jumps from stop to stop.
|
|
22
|
+
|
|
23
|
+
const reducedMotionQuery = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
24
|
+
|
|
25
|
+
export class ScrollTimeline {
|
|
26
|
+
/**
|
|
27
|
+
* @param {HTMLElement} section element whose scroll position drives progress
|
|
28
|
+
* @param {{smoothing?: number, reducedMotion?: boolean}} [opts] smoothing: time constant in seconds (0 = none)
|
|
29
|
+
*/
|
|
30
|
+
constructor(section, opts = {}) {
|
|
31
|
+
this.section = section;
|
|
32
|
+
this.smoothing = opts.smoothing ?? 0.12;
|
|
33
|
+
this.reducedMotion = opts.reducedMotion ?? reducedMotionQuery();
|
|
34
|
+
this.value = NaN; // current (smoothed) progress
|
|
35
|
+
this.target = 0; // progress the scroll position asks for
|
|
36
|
+
this.stale = true;
|
|
37
|
+
this.markStale = () => {
|
|
38
|
+
this.stale = true;
|
|
39
|
+
};
|
|
40
|
+
addEventListener('scroll', this.markStale, { passive: true, capture: true });
|
|
41
|
+
addEventListener('resize', this.markStale, { passive: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
dispose() {
|
|
45
|
+
removeEventListener('scroll', this.markStale, { capture: true });
|
|
46
|
+
removeEventListener('resize', this.markStale);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Stops inside the section: [{el, value}] in document order. */
|
|
50
|
+
stops() {
|
|
51
|
+
const els = [...this.section.querySelectorAll('[data-es3d-stop]')];
|
|
52
|
+
return els.map((el, i) => {
|
|
53
|
+
const raw = el.getAttribute('data-es3d-stop');
|
|
54
|
+
const v = raw === null || raw.trim() === '' ? (els.length > 1 ? i / (els.length - 1) : 0) : Number(raw);
|
|
55
|
+
return { el, value: Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0 };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Progress for the current scroll position (reads layout). */
|
|
60
|
+
measure() {
|
|
61
|
+
const vh = innerHeight || 1;
|
|
62
|
+
const stops = this.stops();
|
|
63
|
+
if (!stops.length) {
|
|
64
|
+
const r = this.section.getBoundingClientRect();
|
|
65
|
+
const range = r.height - vh;
|
|
66
|
+
if (range <= 0) return r.top <= 0 ? 1 : 0;
|
|
67
|
+
return Math.min(1, Math.max(0, -r.top / range));
|
|
68
|
+
}
|
|
69
|
+
// signed distance of each stop's centre below the viewport centre
|
|
70
|
+
const d = stops.map((s) => {
|
|
71
|
+
const r = s.el.getBoundingClientRect();
|
|
72
|
+
return r.top + r.height / 2 - vh / 2;
|
|
73
|
+
});
|
|
74
|
+
if (d[0] >= 0) return stops[0].value;
|
|
75
|
+
const last = stops.length - 1;
|
|
76
|
+
if (d[last] <= 0) return stops[last].value;
|
|
77
|
+
let i = 0;
|
|
78
|
+
while (i < last - 1 && d[i + 1] <= 0) i++;
|
|
79
|
+
const u = d[i] === d[i + 1] ? 0 : -d[i] / (d[i + 1] - d[i]);
|
|
80
|
+
if (this.reducedMotion) return u < 0.5 ? stops[i].value : stops[i + 1].value;
|
|
81
|
+
return stops[i].value + (stops[i + 1].value - stops[i].value) * u;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Advance by dt seconds. Returns true when the value changed (redraw).
|
|
86
|
+
* @param {number} dt
|
|
87
|
+
*/
|
|
88
|
+
update(dt) {
|
|
89
|
+
if (this.stale) {
|
|
90
|
+
this.stale = false;
|
|
91
|
+
this.target = this.measure();
|
|
92
|
+
}
|
|
93
|
+
if (Number.isNaN(this.value) || this.reducedMotion || !(this.smoothing > 0)) {
|
|
94
|
+
if (this.value === this.target) return false;
|
|
95
|
+
this.value = this.target;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
const diff = this.target - this.value;
|
|
99
|
+
if (diff === 0) return false;
|
|
100
|
+
if (Math.abs(diff) < 1e-4) this.value = this.target;
|
|
101
|
+
else this.value += diff * (1 - Math.exp(-Math.max(dt, 0) / this.smoothing));
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// GLSL ES 3.00 shaders for the WebGL2 backend. They mirror shaders/wgsl.js.
|
|
2
|
+
//
|
|
3
|
+
// Instancing: the per-instance world matrix arrives as four vec4 attributes.
|
|
4
|
+
// Normals are transformed with the cofactor matrix of the instance matrix
|
|
5
|
+
// (proportional to the inverse transpose), so no normal matrix is uploaded and
|
|
6
|
+
// non-uniform and mirrored scales are handled correctly.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
MAT_FLAG_MAP, MAT_FLAG_NORMAL_MAP, MAT_FLAG_UNLIT, MAT_FLAG_DOUBLE_SIDED, MAT_FLAG_MR_MAP,
|
|
10
|
+
MAT_FLAG_OCCLUSION, MAT_FLAG_EMISSIVE_MAP, MAT_FLAG_ALPHA_MASK,
|
|
11
|
+
} from '../lighting.js';
|
|
12
|
+
|
|
13
|
+
const FRAME_BLOCK = /* glsl */ `
|
|
14
|
+
layout(std140) uniform Frame {
|
|
15
|
+
mat4 uViewProj;
|
|
16
|
+
mat4 uView;
|
|
17
|
+
vec4 uCameraPos;
|
|
18
|
+
vec4 uSky;
|
|
19
|
+
vec4 uGround;
|
|
20
|
+
vec4 uParams;
|
|
21
|
+
vec4 uLightPos[16];
|
|
22
|
+
vec4 uLightDir[16];
|
|
23
|
+
vec4 uLightColor[16];
|
|
24
|
+
vec4 uLightExtra[16];
|
|
25
|
+
};
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
const MATERIAL_BLOCK = /* glsl */ `
|
|
29
|
+
layout(std140) uniform Material {
|
|
30
|
+
vec4 mBaseColor;
|
|
31
|
+
vec4 mEmissive;
|
|
32
|
+
vec4 mParams;
|
|
33
|
+
vec4 mUv;
|
|
34
|
+
vec4 mOverlay;
|
|
35
|
+
};
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const INSTANCE_INPUTS = /* glsl */ `
|
|
39
|
+
layout(location = 0) in vec3 aPosition;
|
|
40
|
+
layout(location = 1) in vec3 aNormal;
|
|
41
|
+
layout(location = 2) in vec2 aUv;
|
|
42
|
+
layout(location = 3) in vec4 iM0;
|
|
43
|
+
layout(location = 4) in vec4 iM1;
|
|
44
|
+
layout(location = 5) in vec4 iM2;
|
|
45
|
+
layout(location = 6) in vec4 iM3;
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
const COMMON_FS = /* glsl */ `
|
|
49
|
+
const float PI = 3.14159265359;
|
|
50
|
+
vec3 linearToSrgb(vec3 c) {
|
|
51
|
+
c = max(c, vec3(0.0));
|
|
52
|
+
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
|
|
53
|
+
}
|
|
54
|
+
vec3 aces(vec3 x) {
|
|
55
|
+
return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);
|
|
56
|
+
}
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
/** Vertex shader of the mesh programs; texture coordinates only for textured materials. */
|
|
60
|
+
export const meshVertexShader = (textured) => /* glsl */ `#version 300 es
|
|
61
|
+
precision highp float;
|
|
62
|
+
${FRAME_BLOCK}
|
|
63
|
+
${INSTANCE_INPUTS}
|
|
64
|
+
out vec3 vWorld;
|
|
65
|
+
out vec3 vNormal;
|
|
66
|
+
${textured ? 'out vec2 vUv;' : ''}
|
|
67
|
+
void main() {
|
|
68
|
+
mat4 M = mat4(iM0, iM1, iM2, iM3);
|
|
69
|
+
vec4 wp = M * vec4(aPosition, 1.0);
|
|
70
|
+
vec3 c0 = iM0.xyz;
|
|
71
|
+
vec3 c1 = iM1.xyz;
|
|
72
|
+
vec3 c2 = iM2.xyz;
|
|
73
|
+
mat3 cof = mat3(cross(c1, c2), cross(c2, c0), cross(c0, c1));
|
|
74
|
+
float det = dot(c0, cross(c1, c2));
|
|
75
|
+
vNormal = cof * aNormal * (det < 0.0 ? -1.0 : 1.0);
|
|
76
|
+
vWorld = wp.xyz;
|
|
77
|
+
${textured ? 'vUv = aUv;' : ''}
|
|
78
|
+
gl_Position = uViewProj * wp;
|
|
79
|
+
}
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
/** Material flags that need texture coordinates. */
|
|
83
|
+
export const TEXTURE_FLAGS = MAT_FLAG_MAP | MAT_FLAG_NORMAL_MAP | MAT_FLAG_MR_MAP | MAT_FLAG_OCCLUSION | MAT_FLAG_EMISSIVE_MAP;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Fragment shader for one material feature set (MAT_FLAG_* bits) and one
|
|
87
|
+
* light setup (lightTypes() of the frame: its light types, in order). Everything else is
|
|
88
|
+
* compiled out, as a renderer that specialises its programs does: textures
|
|
89
|
+
* are sampled only when the material has them, `discard` exists only for
|
|
90
|
+
* alpha-masked materials (its mere presence disables early depth testing on
|
|
91
|
+
* many GPUs), and the light loop is unrolled with each light's type known.
|
|
92
|
+
*/
|
|
93
|
+
export function meshFragmentShader(flags, lights) {
|
|
94
|
+
const has = (f) => (flags & f) !== 0;
|
|
95
|
+
const map = has(MAT_FLAG_MAP), normalMap = has(MAT_FLAG_NORMAL_MAP), mrMap = has(MAT_FLAG_MR_MAP);
|
|
96
|
+
const occlusion = has(MAT_FLAG_OCCLUSION), emissiveMap = has(MAT_FLAG_EMISSIVE_MAP);
|
|
97
|
+
const textured = map || normalMap || mrMap || occlusion || emissiveMap;
|
|
98
|
+
// explicit gradients only where a discard could leave later lookups without them
|
|
99
|
+
const grad = has(MAT_FLAG_ALPHA_MASK);
|
|
100
|
+
const sample = (sampler) => (grad ? `textureGrad(${sampler}, uv, duv1, duv2)` : `texture(${sampler}, uv)`);
|
|
101
|
+
const L = [];
|
|
102
|
+
L.push(`#version 300 es
|
|
103
|
+
precision highp float;
|
|
104
|
+
precision highp int;
|
|
105
|
+
${FRAME_BLOCK}
|
|
106
|
+
${MATERIAL_BLOCK}`);
|
|
107
|
+
if (map) L.push('uniform sampler2D uMap;');
|
|
108
|
+
if (normalMap) L.push('uniform sampler2D uNormalMap;');
|
|
109
|
+
if (mrMap) L.push('uniform sampler2D uMrMap;');
|
|
110
|
+
if (occlusion) L.push('uniform sampler2D uOcclusionMap;');
|
|
111
|
+
if (emissiveMap) L.push('uniform sampler2D uEmissiveMap;');
|
|
112
|
+
L.push(`in vec3 vWorld;
|
|
113
|
+
in vec3 vNormal;
|
|
114
|
+
${textured ? 'in vec2 vUv;' : ''}
|
|
115
|
+
out vec4 outColor;
|
|
116
|
+
${COMMON_FS}`);
|
|
117
|
+
if (!has(MAT_FLAG_UNLIT)) L.push(BRDF);
|
|
118
|
+
L.push('void main() {');
|
|
119
|
+
if (textured) L.push(' vec2 uv = vUv * mUv.xy;');
|
|
120
|
+
// derivatives before any discard, so every lookup after it still has them
|
|
121
|
+
if ((textured && grad) || normalMap) L.push(' vec2 duv1 = dFdx(uv);', ' vec2 duv2 = dFdy(uv);');
|
|
122
|
+
L.push(' vec4 base = mBaseColor;');
|
|
123
|
+
if (map) L.push(` base *= ${sample('uMap')};`);
|
|
124
|
+
if (has(MAT_FLAG_ALPHA_MASK)) L.push(' if (base.a < mParams.w) discard;');
|
|
125
|
+
L.push(' vec3 emitted = mEmissive.rgb;');
|
|
126
|
+
if (emissiveMap) L.push(` emitted *= ${sample('uEmissiveMap')}.rgb;`);
|
|
127
|
+
if (has(MAT_FLAG_UNLIT)) {
|
|
128
|
+
L.push(' outColor = vec4(linearToSrgb(base.rgb + emitted), base.a);', '}');
|
|
129
|
+
return L.join('\n');
|
|
130
|
+
}
|
|
131
|
+
L.push(' vec3 N = normalize(vNormal);');
|
|
132
|
+
if (has(MAT_FLAG_DOUBLE_SIDED)) L.push(' N = gl_FrontFacing ? N : -N;');
|
|
133
|
+
if (normalMap) {
|
|
134
|
+
L.push(` vec3 dp1 = dFdx(vWorld);
|
|
135
|
+
vec3 dp2 = dFdy(vWorld);
|
|
136
|
+
vec3 dp2perp = cross(dp2, N);
|
|
137
|
+
vec3 dp1perp = cross(N, dp1);
|
|
138
|
+
vec3 T = dp2perp * duv1.x + dp1perp * duv2.x;
|
|
139
|
+
vec3 B = dp2perp * duv1.y + dp1perp * duv2.y;
|
|
140
|
+
float invmax = inversesqrt(max(max(dot(T, T), dot(B, B)), 1e-20));
|
|
141
|
+
vec3 n = ${sample('uNormalMap')}.xyz * 2.0 - 1.0;
|
|
142
|
+
n.xy *= mEmissive.w;
|
|
143
|
+
N = normalize(mat3(T * invmax, B * invmax, N) * n);`);
|
|
144
|
+
}
|
|
145
|
+
L.push(` vec3 V = normalize(uCameraPos.xyz - vWorld);
|
|
146
|
+
float NdotV = max(dot(N, V), 1e-4);
|
|
147
|
+
float rough = clamp(mParams.y, 0.045, 1.0);
|
|
148
|
+
float metal = clamp(mParams.x, 0.0, 1.0);`);
|
|
149
|
+
if (mrMap) {
|
|
150
|
+
L.push(` vec4 mr = ${sample('uMrMap')};
|
|
151
|
+
rough = clamp(rough * mr.g, 0.045, 1.0);
|
|
152
|
+
metal = clamp(metal * mr.b, 0.0, 1.0);`);
|
|
153
|
+
}
|
|
154
|
+
L.push(` float a = rough * rough;
|
|
155
|
+
vec3 f0 = mix(vec3(0.04), base.rgb, metal);
|
|
156
|
+
vec3 diffuse = base.rgb * (1.0 - metal);
|
|
157
|
+
vec3 color = vec3(0.0);
|
|
158
|
+
vec3 ambient = vec3(0.0);`);
|
|
159
|
+
if (lights) lights.split(',').forEach((type, i) => L.push(lightTerm(Number(type), i)));
|
|
160
|
+
L.push(` float envI = uSky.w;
|
|
161
|
+
vec3 hemi = mix(uGround.rgb, uSky.rgb, N.y * 0.5 + 0.5) * envI + ambient;
|
|
162
|
+
vec3 R = reflect(-V, N);
|
|
163
|
+
vec3 envSpec = mix(uGround.rgb, uSky.rgb, smoothstep(-0.25 - rough, 0.25 + rough, R.y)) * envI;
|
|
164
|
+
envSpec = mix(envSpec, hemi, rough * rough);`);
|
|
165
|
+
if (occlusion) {
|
|
166
|
+
L.push(` float ao = mix(1.0, ${sample('uOcclusionMap')}.r, mUv.z);
|
|
167
|
+
hemi *= ao;
|
|
168
|
+
envSpec *= ao;`);
|
|
169
|
+
}
|
|
170
|
+
L.push(` color += diffuse * hemi + envSpec * envBRDF(f0, rough, NdotV);
|
|
171
|
+
color += emitted;
|
|
172
|
+
color *= uGround.w;
|
|
173
|
+
outColor = vec4(linearToSrgb(aces(color)), base.a);
|
|
174
|
+
}`);
|
|
175
|
+
return L.join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const BRDF = /* glsl */ `
|
|
179
|
+
float D_GGX(float NdotH, float a) {
|
|
180
|
+
float a2 = a * a;
|
|
181
|
+
float d = NdotH * NdotH * (a2 - 1.0) + 1.0;
|
|
182
|
+
return a2 / (PI * d * d);
|
|
183
|
+
}
|
|
184
|
+
float V_Smith(float NdotV, float NdotL, float a) {
|
|
185
|
+
float a2 = a * a;
|
|
186
|
+
float gv = NdotL * sqrt(NdotV * NdotV * (1.0 - a2) + a2);
|
|
187
|
+
float gl = NdotV * sqrt(NdotL * NdotL * (1.0 - a2) + a2);
|
|
188
|
+
return 0.5 / max(gv + gl, 1e-5);
|
|
189
|
+
}
|
|
190
|
+
vec3 F_Schlick(vec3 f0, float VdotH) {
|
|
191
|
+
return f0 + (vec3(1.0) - f0) * pow(1.0 - VdotH, 5.0);
|
|
192
|
+
}
|
|
193
|
+
vec3 envBRDF(vec3 f0, float roughness, float NdotV) {
|
|
194
|
+
const vec4 c0 = vec4(-1.0, -0.0275, -0.572, 0.022);
|
|
195
|
+
const vec4 c1 = vec4(1.0, 0.0425, 1.04, -0.04);
|
|
196
|
+
vec4 r = roughness * c0 + c1;
|
|
197
|
+
float a004 = min(r.x * r.x, exp2(-9.28 * NdotV)) * r.x + r.y;
|
|
198
|
+
vec2 AB = vec2(-1.04, 1.04) * a004 + r.zw;
|
|
199
|
+
return f0 * AB.x + AB.y;
|
|
200
|
+
}
|
|
201
|
+
// one light's contribution, zero when it is behind the surface
|
|
202
|
+
vec3 shade(vec3 N, vec3 V, vec3 L, vec3 radiance, float NdotV, vec3 f0, vec3 diffuse, float a) {
|
|
203
|
+
float NdotL = dot(N, L);
|
|
204
|
+
if (NdotL <= 0.0) return vec3(0.0);
|
|
205
|
+
vec3 H = normalize(V + L);
|
|
206
|
+
float NdotH = max(dot(N, H), 0.0);
|
|
207
|
+
float VdotH = max(dot(V, H), 0.0);
|
|
208
|
+
vec3 F = F_Schlick(f0, VdotH);
|
|
209
|
+
vec3 spec = D_GGX(NdotH, a) * V_Smith(NdotV, NdotL, a) * F;
|
|
210
|
+
vec3 kd = (vec3(1.0) - F) * diffuse / PI;
|
|
211
|
+
return (kd + spec) * radiance * NdotL;
|
|
212
|
+
}
|
|
213
|
+
`;
|
|
214
|
+
|
|
215
|
+
/** GLSL for light i of a known type (lighting.js: 0 directional, 1 point, 2 spot, 3 ambient, 4 hemisphere). */
|
|
216
|
+
function lightTerm(type, i) {
|
|
217
|
+
if (type === 3) return ` ambient += uLightColor[${i}].rgb;`;
|
|
218
|
+
if (type === 4) return ` ambient += mix(uLightExtra[${i}].yzw, uLightColor[${i}].rgb, N.y * 0.5 + 0.5);`;
|
|
219
|
+
if (type === 0) return ` color += shade(N, V, -uLightDir[${i}].xyz, uLightColor[${i}].rgb * PI, NdotV, f0, diffuse, a);`;
|
|
220
|
+
return ` {
|
|
221
|
+
vec3 Lv = uLightPos[${i}].xyz - vWorld;
|
|
222
|
+
float d = length(Lv);
|
|
223
|
+
vec3 L = Lv / max(d, 1e-5);
|
|
224
|
+
float att = 1.0 / max(d * d, 1e-4);
|
|
225
|
+
float range = uLightDir[${i}].w;
|
|
226
|
+
if (range > 0.0) {
|
|
227
|
+
float x = d / range;
|
|
228
|
+
float w = clamp(1.0 - x * x * x * x, 0.0, 1.0);
|
|
229
|
+
att *= w * w;
|
|
230
|
+
}${type === 2 ? `\n att *= smoothstep(uLightColor[${i}].w, uLightExtra[${i}].x, dot(-L, uLightDir[${i}].xyz));` : ''}
|
|
231
|
+
color += shade(N, V, L, uLightColor[${i}].rgb * PI * att, NdotV, f0, diffuse, a);
|
|
232
|
+
}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Lines: scene polylines (unlit material color), wireframe and bounding boxes. */
|
|
236
|
+
export const LINE_VS = /* glsl */ `#version 300 es
|
|
237
|
+
precision highp float;
|
|
238
|
+
${FRAME_BLOCK}
|
|
239
|
+
${INSTANCE_INPUTS}
|
|
240
|
+
void main() {
|
|
241
|
+
mat4 M = mat4(iM0, iM1, iM2, iM3);
|
|
242
|
+
vec4 wp = M * vec4(aPosition, 1.0);
|
|
243
|
+
// pull lines slightly toward the camera so they win the depth test against
|
|
244
|
+
// the surfaces they outline (a relative offset works at every distance)
|
|
245
|
+
wp.xyz = mix(wp.xyz, uCameraPos.xyz, uParams.z);
|
|
246
|
+
gl_Position = uViewProj * wp;
|
|
247
|
+
}
|
|
248
|
+
`;
|
|
249
|
+
|
|
250
|
+
/** Normal visualisation: the tip vertex (uv.x = 1) is offset along the world normal. */
|
|
251
|
+
export const NORMAL_VS = /* glsl */ `#version 300 es
|
|
252
|
+
precision highp float;
|
|
253
|
+
${FRAME_BLOCK}
|
|
254
|
+
${INSTANCE_INPUTS}
|
|
255
|
+
void main() {
|
|
256
|
+
mat4 M = mat4(iM0, iM1, iM2, iM3);
|
|
257
|
+
vec4 wp = M * vec4(aPosition, 1.0);
|
|
258
|
+
vec3 c0 = iM0.xyz;
|
|
259
|
+
vec3 c1 = iM1.xyz;
|
|
260
|
+
vec3 c2 = iM2.xyz;
|
|
261
|
+
mat3 cof = mat3(cross(c1, c2), cross(c2, c0), cross(c0, c1));
|
|
262
|
+
float det = dot(c0, cross(c1, c2));
|
|
263
|
+
vec3 n = normalize(cof * aNormal * (det < 0.0 ? -1.0 : 1.0));
|
|
264
|
+
wp.xyz += n * aUv.x * uParams.y;
|
|
265
|
+
wp.xyz = mix(wp.xyz, uCameraPos.xyz, uParams.z);
|
|
266
|
+
gl_Position = uViewProj * wp;
|
|
267
|
+
}
|
|
268
|
+
`;
|
|
269
|
+
|
|
270
|
+
export const LINE_FS = /* glsl */ `#version 300 es
|
|
271
|
+
precision highp float;
|
|
272
|
+
precision highp int;
|
|
273
|
+
${FRAME_BLOCK}
|
|
274
|
+
${MATERIAL_BLOCK}
|
|
275
|
+
out vec4 outColor;
|
|
276
|
+
${COMMON_FS}
|
|
277
|
+
void main() {
|
|
278
|
+
int flags = int(mParams.z + 0.5);
|
|
279
|
+
if ((flags & 16) != 0) {
|
|
280
|
+
outColor = mOverlay;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
outColor = vec4(linearToSrgb(mBaseColor.rgb + mEmissive.rgb), mBaseColor.a);
|
|
284
|
+
}
|
|
285
|
+
`;
|