@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,252 @@
|
|
|
1
|
+
// WGSL shaders for the WebGPU backend. They mirror shaders/glsl.js.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
MAT_FLAG_MAP, MAT_FLAG_NORMAL_MAP, MAT_FLAG_UNLIT, MAT_FLAG_DOUBLE_SIDED, MAT_FLAG_MR_MAP,
|
|
5
|
+
MAT_FLAG_OCCLUSION, MAT_FLAG_EMISSIVE_MAP, MAT_FLAG_ALPHA_MASK,
|
|
6
|
+
} from '../lighting.js';
|
|
7
|
+
|
|
8
|
+
const STRUCTS = /* wgsl */ `
|
|
9
|
+
struct Frame {
|
|
10
|
+
viewProj: mat4x4f,
|
|
11
|
+
view: mat4x4f,
|
|
12
|
+
cameraPos: vec4f,
|
|
13
|
+
sky: vec4f,
|
|
14
|
+
ground: vec4f,
|
|
15
|
+
params: vec4f,
|
|
16
|
+
lightPos: array<vec4f, 16>,
|
|
17
|
+
lightDir: array<vec4f, 16>,
|
|
18
|
+
lightColor: array<vec4f, 16>,
|
|
19
|
+
lightExtra: array<vec4f, 16>,
|
|
20
|
+
};
|
|
21
|
+
struct Material {
|
|
22
|
+
baseColor: vec4f,
|
|
23
|
+
emissive: vec4f,
|
|
24
|
+
params: vec4f,
|
|
25
|
+
uvScale: vec4f,
|
|
26
|
+
overlay: vec4f,
|
|
27
|
+
};
|
|
28
|
+
@group(0) @binding(0) var<uniform> frame: Frame;
|
|
29
|
+
@group(1) @binding(0) var<uniform> material: Material;
|
|
30
|
+
@group(1) @binding(1) var samp: sampler;
|
|
31
|
+
@group(1) @binding(2) var baseTex: texture_2d<f32>;
|
|
32
|
+
@group(1) @binding(3) var normalTex: texture_2d<f32>;
|
|
33
|
+
@group(1) @binding(4) var mrTex: texture_2d<f32>;
|
|
34
|
+
@group(1) @binding(5) var occlusionTex: texture_2d<f32>;
|
|
35
|
+
@group(1) @binding(6) var emissiveTex: texture_2d<f32>;
|
|
36
|
+
|
|
37
|
+
struct VSIn {
|
|
38
|
+
@location(0) position: vec3f,
|
|
39
|
+
@location(1) normal: vec3f,
|
|
40
|
+
@location(2) uv: vec2f,
|
|
41
|
+
@location(3) m0: vec4f,
|
|
42
|
+
@location(4) m1: vec4f,
|
|
43
|
+
@location(5) m2: vec4f,
|
|
44
|
+
@location(6) m3: vec4f,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const PI = 3.14159265359;
|
|
48
|
+
|
|
49
|
+
fn linearToSrgb(c0: vec3f) -> vec3f {
|
|
50
|
+
let c = max(c0, vec3f(0.0));
|
|
51
|
+
return select(1.055 * pow(c, vec3f(1.0 / 2.4)) - 0.055, c * 12.92, c <= vec3f(0.0031308));
|
|
52
|
+
}
|
|
53
|
+
fn aces(x: vec3f) -> vec3f {
|
|
54
|
+
return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), vec3f(0.0), vec3f(1.0));
|
|
55
|
+
}
|
|
56
|
+
fn worldNormal(inp: VSIn) -> vec3f {
|
|
57
|
+
let c0 = inp.m0.xyz;
|
|
58
|
+
let c1 = inp.m1.xyz;
|
|
59
|
+
let c2 = inp.m2.xyz;
|
|
60
|
+
let cof = mat3x3f(cross(c1, c2), cross(c2, c0), cross(c0, c1));
|
|
61
|
+
let det = dot(c0, cross(c1, c2));
|
|
62
|
+
return cof * inp.normal * select(1.0, -1.0, det < 0.0);
|
|
63
|
+
}
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
/** Vertex stage of the mesh modules; texture coordinates only for textured materials. */
|
|
67
|
+
const meshVertex = (textured) => /* wgsl */ `
|
|
68
|
+
struct VSOut {
|
|
69
|
+
@builtin(position) pos: vec4f,
|
|
70
|
+
@location(0) world: vec3f,
|
|
71
|
+
@location(1) normal: vec3f,
|
|
72
|
+
${textured ? '@location(2) uv: vec2f,' : ''}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
@vertex
|
|
76
|
+
fn vs_main(inp: VSIn) -> VSOut {
|
|
77
|
+
let M = mat4x4f(inp.m0, inp.m1, inp.m2, inp.m3);
|
|
78
|
+
let wp = M * vec4f(inp.position, 1.0);
|
|
79
|
+
var out: VSOut;
|
|
80
|
+
out.pos = frame.viewProj * wp;
|
|
81
|
+
out.world = wp.xyz;
|
|
82
|
+
out.normal = worldNormal(inp);
|
|
83
|
+
${textured ? 'out.uv = inp.uv;' : ''}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
const BRDF = /* wgsl */ `
|
|
89
|
+
fn D_GGX(NdotH: f32, a: f32) -> f32 {
|
|
90
|
+
let a2 = a * a;
|
|
91
|
+
let d = NdotH * NdotH * (a2 - 1.0) + 1.0;
|
|
92
|
+
return a2 / (PI * d * d);
|
|
93
|
+
}
|
|
94
|
+
fn V_Smith(NdotV: f32, NdotL: f32, a: f32) -> f32 {
|
|
95
|
+
let a2 = a * a;
|
|
96
|
+
let gv = NdotL * sqrt(NdotV * NdotV * (1.0 - a2) + a2);
|
|
97
|
+
let gl = NdotV * sqrt(NdotL * NdotL * (1.0 - a2) + a2);
|
|
98
|
+
return 0.5 / max(gv + gl, 1e-5);
|
|
99
|
+
}
|
|
100
|
+
fn F_Schlick(f0: vec3f, VdotH: f32) -> vec3f {
|
|
101
|
+
return f0 + (vec3f(1.0) - f0) * pow(1.0 - VdotH, 5.0);
|
|
102
|
+
}
|
|
103
|
+
fn envBRDF(f0: vec3f, roughness: f32, NdotV: f32) -> vec3f {
|
|
104
|
+
let c0 = vec4f(-1.0, -0.0275, -0.572, 0.022);
|
|
105
|
+
let c1 = vec4f(1.0, 0.0425, 1.04, -0.04);
|
|
106
|
+
let r = roughness * c0 + c1;
|
|
107
|
+
let a004 = min(r.x * r.x, exp2(-9.28 * NdotV)) * r.x + r.y;
|
|
108
|
+
let AB = vec2f(-1.04, 1.04) * a004 + r.zw;
|
|
109
|
+
return f0 * AB.x + AB.y;
|
|
110
|
+
}
|
|
111
|
+
// one light's contribution, zero when it is behind the surface
|
|
112
|
+
fn shade(N: vec3f, V: vec3f, L: vec3f, radiance: vec3f, NdotV: f32, f0: vec3f, diffuse: vec3f, a: f32) -> vec3f {
|
|
113
|
+
let NdotL = dot(N, L);
|
|
114
|
+
if (NdotL <= 0.0) {
|
|
115
|
+
return vec3f(0.0);
|
|
116
|
+
}
|
|
117
|
+
let H = normalize(V + L);
|
|
118
|
+
let NdotH = max(dot(N, H), 0.0);
|
|
119
|
+
let VdotH = max(dot(V, H), 0.0);
|
|
120
|
+
let F = F_Schlick(f0, VdotH);
|
|
121
|
+
let spec = D_GGX(NdotH, a) * V_Smith(NdotV, NdotL, a) * F;
|
|
122
|
+
let kd = (vec3f(1.0) - F) * diffuse / PI;
|
|
123
|
+
return (kd + spec) * radiance * NdotL;
|
|
124
|
+
}
|
|
125
|
+
`;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Shader module for one material feature set (MAT_FLAG_* bits) and one light
|
|
129
|
+
* setup (lightTypes() of the frame), as meshFragmentShader() in
|
|
130
|
+
* glsl.js: everything the material does not use is compiled out, and the
|
|
131
|
+
* light loop is unrolled. Textures are sampled in uniform control flow (the
|
|
132
|
+
* double-sided normal flip is a select, not a branch); after an alpha-mask
|
|
133
|
+
* discard, lookups use explicit gradients taken before it.
|
|
134
|
+
*/
|
|
135
|
+
export function meshShader(flags, lights) {
|
|
136
|
+
const has = (f) => (flags & f) !== 0;
|
|
137
|
+
const map = has(MAT_FLAG_MAP), normalMap = has(MAT_FLAG_NORMAL_MAP), mrMap = has(MAT_FLAG_MR_MAP);
|
|
138
|
+
const occlusion = has(MAT_FLAG_OCCLUSION), emissiveMap = has(MAT_FLAG_EMISSIVE_MAP);
|
|
139
|
+
const textured = map || normalMap || mrMap || occlusion || emissiveMap;
|
|
140
|
+
const grad = has(MAT_FLAG_ALPHA_MASK);
|
|
141
|
+
const sample = (tex) => (grad ? `textureSampleGrad(${tex}, samp, uv, ddx, ddy)` : `textureSample(${tex}, samp, uv)`);
|
|
142
|
+
const L = [STRUCTS, meshVertex(textured)];
|
|
143
|
+
if (!has(MAT_FLAG_UNLIT)) L.push(BRDF);
|
|
144
|
+
L.push('@fragment', 'fn fs_main(inp: VSOut, @builtin(front_facing) frontFacing: bool) -> @location(0) vec4f {');
|
|
145
|
+
if (textured) L.push(' let uv = inp.uv * material.uvScale.xy;');
|
|
146
|
+
if ((textured && grad) || normalMap) L.push(' let ddx = dpdx(uv);', ' let ddy = dpdy(uv);');
|
|
147
|
+
if (normalMap) {
|
|
148
|
+
// WGSL derivatives use framebuffer y (down); negate dpdy to match GLSL's
|
|
149
|
+
// window-space dFdy (up) so the cotangent frame has the same handedness
|
|
150
|
+
L.push(' let dp1 = dpdx(inp.world);', ' let dp2 = -dpdy(inp.world);', ' let duv1 = ddx;', ' let duv2 = -ddy;');
|
|
151
|
+
}
|
|
152
|
+
L.push(' var base = material.baseColor;');
|
|
153
|
+
if (map) L.push(` base = base * ${sample('baseTex')};`);
|
|
154
|
+
if (grad) L.push(' if (base.a < material.params.w) {', ' discard;', ' }');
|
|
155
|
+
L.push(' var emitted = material.emissive.rgb;');
|
|
156
|
+
if (emissiveMap) L.push(` emitted = emitted * ${sample('emissiveTex')}.rgb;`);
|
|
157
|
+
if (has(MAT_FLAG_UNLIT)) {
|
|
158
|
+
L.push(' return vec4f(linearToSrgb(base.rgb + emitted), base.a);', '}');
|
|
159
|
+
return L.join('\n');
|
|
160
|
+
}
|
|
161
|
+
L.push(' var N = normalize(inp.normal);');
|
|
162
|
+
if (has(MAT_FLAG_DOUBLE_SIDED)) L.push(' N = select(-N, N, frontFacing);');
|
|
163
|
+
if (normalMap) {
|
|
164
|
+
L.push(` let dp2perp = cross(dp2, N);
|
|
165
|
+
let dp1perp = cross(N, dp1);
|
|
166
|
+
let T = dp2perp * duv1.x + dp1perp * duv2.x;
|
|
167
|
+
let B = dp2perp * duv1.y + dp1perp * duv2.y;
|
|
168
|
+
let invmax = inverseSqrt(max(max(dot(T, T), dot(B, B)), 1e-20));
|
|
169
|
+
let n0 = ${sample('normalTex')}.xyz * 2.0 - 1.0;
|
|
170
|
+
let n = vec3f(n0.xy * material.emissive.w, n0.z);
|
|
171
|
+
N = normalize(mat3x3f(T * invmax, B * invmax, N) * n);`);
|
|
172
|
+
}
|
|
173
|
+
L.push(` let V = normalize(frame.cameraPos.xyz - inp.world);
|
|
174
|
+
let NdotV = max(dot(N, V), 1e-4);
|
|
175
|
+
var rough = clamp(material.params.y, 0.045, 1.0);
|
|
176
|
+
var metal = clamp(material.params.x, 0.0, 1.0);`);
|
|
177
|
+
if (mrMap) {
|
|
178
|
+
L.push(` let mr = ${sample('mrTex')};
|
|
179
|
+
rough = clamp(rough * mr.g, 0.045, 1.0);
|
|
180
|
+
metal = clamp(metal * mr.b, 0.0, 1.0);`);
|
|
181
|
+
}
|
|
182
|
+
L.push(` let a = rough * rough;
|
|
183
|
+
let f0 = mix(vec3f(0.04), base.rgb, metal);
|
|
184
|
+
let diffuse = base.rgb * (1.0 - metal);
|
|
185
|
+
var color = vec3f(0.0);
|
|
186
|
+
var ambient = vec3f(0.0);`);
|
|
187
|
+
if (lights) lights.split(',').forEach((type, i) => L.push(lightTerm(Number(type), i)));
|
|
188
|
+
L.push(` let envI = frame.sky.w;
|
|
189
|
+
var hemi = mix(frame.ground.rgb, frame.sky.rgb, N.y * 0.5 + 0.5) * envI + ambient;
|
|
190
|
+
let R = reflect(-V, N);
|
|
191
|
+
var envSpec = mix(frame.ground.rgb, frame.sky.rgb, smoothstep(-0.25 - rough, 0.25 + rough, R.y)) * envI;
|
|
192
|
+
envSpec = mix(envSpec, hemi, rough * rough);`);
|
|
193
|
+
if (occlusion) {
|
|
194
|
+
L.push(` let ao = mix(1.0, ${sample('occlusionTex')}.r, material.uvScale.z);
|
|
195
|
+
hemi = hemi * ao;
|
|
196
|
+
envSpec = envSpec * ao;`);
|
|
197
|
+
}
|
|
198
|
+
L.push(` color = color + diffuse * hemi + envSpec * envBRDF(f0, rough, NdotV);
|
|
199
|
+
color = color + emitted;
|
|
200
|
+
color = color * frame.ground.w;
|
|
201
|
+
return vec4f(linearToSrgb(aces(color)), base.a);
|
|
202
|
+
}`);
|
|
203
|
+
return L.join('\n');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** WGSL for light i of a known type (lighting.js: 0 directional, 1 point, 2 spot, 3 ambient, 4 hemisphere). */
|
|
207
|
+
function lightTerm(type, i) {
|
|
208
|
+
if (type === 3) return ` ambient = ambient + frame.lightColor[${i}].rgb;`;
|
|
209
|
+
if (type === 4) return ` ambient = ambient + mix(frame.lightExtra[${i}].yzw, frame.lightColor[${i}].rgb, N.y * 0.5 + 0.5);`;
|
|
210
|
+
if (type === 0) return ` color = color + shade(N, V, -frame.lightDir[${i}].xyz, frame.lightColor[${i}].rgb * PI, NdotV, f0, diffuse, a);`;
|
|
211
|
+
return ` {
|
|
212
|
+
let Lv = frame.lightPos[${i}].xyz - inp.world;
|
|
213
|
+
let d = length(Lv);
|
|
214
|
+
let L = Lv / max(d, 1e-5);
|
|
215
|
+
var att = 1.0 / max(d * d, 1e-4);
|
|
216
|
+
let range = frame.lightDir[${i}].w;
|
|
217
|
+
if (range > 0.0) {
|
|
218
|
+
let x = d / range;
|
|
219
|
+
let w = clamp(1.0 - x * x * x * x, 0.0, 1.0);
|
|
220
|
+
att = att * w * w;
|
|
221
|
+
}${type === 2 ? `\n att = att * smoothstep(frame.lightColor[${i}].w, frame.lightExtra[${i}].x, dot(-L, frame.lightDir[${i}].xyz));` : ''}
|
|
222
|
+
color = color + shade(N, V, L, frame.lightColor[${i}].rgb * PI * att, NdotV, f0, diffuse, a);
|
|
223
|
+
}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export const LINE_WGSL = /* wgsl */ `
|
|
227
|
+
${STRUCTS}
|
|
228
|
+
@vertex
|
|
229
|
+
fn vs_line(inp: VSIn) -> @builtin(position) vec4f {
|
|
230
|
+
let M = mat4x4f(inp.m0, inp.m1, inp.m2, inp.m3);
|
|
231
|
+
let wp = M * vec4f(inp.position, 1.0);
|
|
232
|
+
return frame.viewProj * vec4f(mix(wp.xyz, frame.cameraPos.xyz, frame.params.z), 1.0);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
@vertex
|
|
236
|
+
fn vs_normals(inp: VSIn) -> @builtin(position) vec4f {
|
|
237
|
+
let M = mat4x4f(inp.m0, inp.m1, inp.m2, inp.m3);
|
|
238
|
+
var wp = M * vec4f(inp.position, 1.0);
|
|
239
|
+
let n = normalize(worldNormal(inp));
|
|
240
|
+
wp = vec4f(wp.xyz + n * inp.uv.x * frame.params.y, 1.0);
|
|
241
|
+
return frame.viewProj * vec4f(mix(wp.xyz, frame.cameraPos.xyz, frame.params.z), 1.0);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@fragment
|
|
245
|
+
fn fs_line() -> @location(0) vec4f {
|
|
246
|
+
let flags = u32(material.params.z + 0.5);
|
|
247
|
+
if ((flags & 16u) != 0u) {
|
|
248
|
+
return material.overlay;
|
|
249
|
+
}
|
|
250
|
+
return vec4f(linearToSrgb(material.baseColor.rgb + material.emissive.rgb), material.baseColor.a);
|
|
251
|
+
}
|
|
252
|
+
`;
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// A stage: an ES3D document on a canvas, ready for a web page.
|
|
2
|
+
//
|
|
3
|
+
// const stage = await createStage(canvas, { scroll: document.querySelector('#tour') });
|
|
4
|
+
// stage.show(source);
|
|
5
|
+
//
|
|
6
|
+
// It compiles the document, draws it with the renderer, and handles what a
|
|
7
|
+
// page needs around that:
|
|
8
|
+
//
|
|
9
|
+
// - Draws only when something visible changed (camera, animation, size, a
|
|
10
|
+
// texture arriving), and not at all while the canvas is scrolled away.
|
|
11
|
+
// - Applies the document's <controls>: orbit limits, collision with the scene,
|
|
12
|
+
// no dragging (mode="none") or a camera driven by page scroll (mode="scroll").
|
|
13
|
+
// - Drives timeline="scroll" animations from the scroll position of a page
|
|
14
|
+
// section (see ScrollTimeline), and builds the meshes the scroll path will
|
|
15
|
+
// need while the browser is idle, so scrolling does not stutter later.
|
|
16
|
+
// - Keeps geometry between documents: show() reuses the previous document's
|
|
17
|
+
// geometry cache, so swapping a colour re-tessellates and re-uploads nothing.
|
|
18
|
+
// - Keeps geometry between visits: the meshes a document's shapes produced
|
|
19
|
+
// are stored in the browser (IndexedDB, src/cache/geometry-store.js) once
|
|
20
|
+
// the page is idle, and read back instead of tessellated on the next visit.
|
|
21
|
+
//
|
|
22
|
+
// Reduced motion (prefers-reduced-motion) stops clock animations and the
|
|
23
|
+
// automatic orbit; scroll-driven cameras jump between stops.
|
|
24
|
+
|
|
25
|
+
import { compile } from '../pipeline.js';
|
|
26
|
+
import { updateDynamic } from '../scene/flatten.js';
|
|
27
|
+
import { pick } from '../scene/picking.js';
|
|
28
|
+
import { createRenderer, PROGRESSIVE_BUDGET_MS } from './renderer.js';
|
|
29
|
+
import { openGeometryStore, persistGeometry } from '../cache/geometry-store.js';
|
|
30
|
+
import { Camera } from './camera.js';
|
|
31
|
+
import { OrbitControls } from './orbit-controls.js';
|
|
32
|
+
import { ScrollTimeline } from './scroll-timeline.js';
|
|
33
|
+
|
|
34
|
+
const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
35
|
+
const idle = (fn) => (typeof requestIdleCallback === 'function' ? requestIdleCallback(fn, { timeout: 500 }) : setTimeout(fn, 16));
|
|
36
|
+
|
|
37
|
+
/** Names (name or id) on an element and its ancestors, nearest first. */
|
|
38
|
+
export function ancestorNames(doc, uid) {
|
|
39
|
+
const names = [];
|
|
40
|
+
let el = doc && doc.byUid.get(uid);
|
|
41
|
+
while (el) {
|
|
42
|
+
const n = el.values && (el.values.name || el.values.id);
|
|
43
|
+
if (n) names.push(n);
|
|
44
|
+
el = el.parent;
|
|
45
|
+
}
|
|
46
|
+
return names;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Views sampled along a scroll path to warm up its meshes. */
|
|
50
|
+
const PREFETCH_SAMPLES = 24;
|
|
51
|
+
|
|
52
|
+
/** How long a new document waits for its stored meshes before drawing without them (ms). */
|
|
53
|
+
const RESTORE_WAIT_MS = 50;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {HTMLCanvasElement} canvas sized by its parent element
|
|
57
|
+
* @param {object} [opts]
|
|
58
|
+
* @param {boolean} [opts.playing=true] play clock animations
|
|
59
|
+
* @param {boolean} [opts.autoOrbit=true] turn slowly around the target until someone drags
|
|
60
|
+
* @param {number} [opts.orbitSpeed=0.22] radians per second
|
|
61
|
+
* @param {boolean} [opts.interactive=true] false: no dragging, whatever the document says
|
|
62
|
+
* @param {HTMLElement} [opts.scroll] section whose scroll position drives timeline="scroll"
|
|
63
|
+
* animations (default: the whole page)
|
|
64
|
+
* @param {string} [opts.backend] 'auto' | 'webgpu' | 'webgl2'
|
|
65
|
+
* @param {(dt:number, stage:object) => void} [opts.onFrame] called every animation frame
|
|
66
|
+
* @param {(hit: object|null) => void} [opts.onPick] click on the scene; hit.names lists name/id up the tree
|
|
67
|
+
* @param {() => void} [opts.onInteract] the visitor moved the camera
|
|
68
|
+
* @param {(message: string, result: object) => void} [opts.onError] the document did not compile
|
|
69
|
+
* @param {object|Promise<object>|false} [opts.geometryStore] where generated meshes are kept
|
|
70
|
+
* between visits (default: IndexedDB, openGeometryStore()); false keeps nothing
|
|
71
|
+
*/
|
|
72
|
+
export async function createStage(canvas, opts = {}) {
|
|
73
|
+
// opened while the renderer starts, so the first show() rarely waits for it
|
|
74
|
+
const persist = persistGeometry(opts.geometryStore === false ? null : opts.geometryStore || openGeometryStore());
|
|
75
|
+
const renderer = await createRenderer(canvas, { backend: opts.backend });
|
|
76
|
+
renderer.options.tessellationBudget = PROGRESSIVE_BUDGET_MS;
|
|
77
|
+
const parent = canvas.parentElement;
|
|
78
|
+
const target = renderer.canvas || canvas;
|
|
79
|
+
target.style.display = 'block';
|
|
80
|
+
const camera = new Camera();
|
|
81
|
+
const controls = new OrbitControls(target, camera);
|
|
82
|
+
const motion = !reducedMotion();
|
|
83
|
+
const state = {
|
|
84
|
+
result: null,
|
|
85
|
+
time: 0,
|
|
86
|
+
progress: 0,
|
|
87
|
+
last: 0,
|
|
88
|
+
disposed: false,
|
|
89
|
+
playing: opts.playing !== false && motion,
|
|
90
|
+
auto: opts.autoOrbit !== false && motion,
|
|
91
|
+
orbitSpeed: opts.orbitSpeed ?? 0.22,
|
|
92
|
+
onScreen: true,
|
|
93
|
+
// A frame is drawn only when something visible changed: the camera, the
|
|
94
|
+
// animation time or scroll progress, the size, the scene or a texture.
|
|
95
|
+
dirty: true,
|
|
96
|
+
sceneTime: NaN, // time and progress the animated instances were last evaluated at
|
|
97
|
+
sceneProgress: NaN,
|
|
98
|
+
width: 0,
|
|
99
|
+
height: 0,
|
|
100
|
+
timeline: null,
|
|
101
|
+
generation: 0, // bumps on show(), to cancel stale idle work
|
|
102
|
+
holdUntil: 0, // no drawing before this time: a new document's stored meshes are on their way
|
|
103
|
+
};
|
|
104
|
+
const invalidate = () => {
|
|
105
|
+
state.dirty = true;
|
|
106
|
+
};
|
|
107
|
+
renderer.textures.onReady = invalidate;
|
|
108
|
+
|
|
109
|
+
// Camera state at the last drawn frame; onFrame callbacks move the camera directly.
|
|
110
|
+
const seen = new Float64Array(15).fill(NaN);
|
|
111
|
+
let moved = false;
|
|
112
|
+
const see = (i, v) => {
|
|
113
|
+
if (seen[i] !== v) {
|
|
114
|
+
seen[i] = v;
|
|
115
|
+
moved = true;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const cameraMoved = () => {
|
|
119
|
+
const c = camera;
|
|
120
|
+
moved = false;
|
|
121
|
+
for (let k = 0; k < 3; k++) {
|
|
122
|
+
see(k, c.position[k]);
|
|
123
|
+
see(3 + k, c.target[k]);
|
|
124
|
+
see(6 + k, c.up[k]);
|
|
125
|
+
}
|
|
126
|
+
see(9, c.fov);
|
|
127
|
+
see(10, c.orthoHeight);
|
|
128
|
+
see(11, c.type === 'orthographic' ? 1 : 0);
|
|
129
|
+
see(12, c.near ?? -1);
|
|
130
|
+
see(13, c.refAspect);
|
|
131
|
+
see(14, c.fit === 'cover' ? 1 : c.fit === 'width' ? 2 : c.fit === 'height' ? 3 : 0);
|
|
132
|
+
return moved;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const meshOf = (entry, lod) => state.result.cache.mesh(entry, lod);
|
|
136
|
+
const lodVisible = (scene) => {
|
|
137
|
+
const lods = renderer.frames.lodStates(scene, camera.position, (p) => Math.hypot(p[0] - camera.position[0], p[1] - camera.position[1], p[2] - camera.position[2]));
|
|
138
|
+
return (i) => {
|
|
139
|
+
const ln = scene.lodNode[i];
|
|
140
|
+
return ln < 0 || (lods.visible[ln] && lods.active[ln] === scene.lodChild[i]);
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
const world = () => (state.result ? { scene: state.result.scene, meshOf, isVisible: lodVisible(state.result.scene) } : null);
|
|
144
|
+
|
|
145
|
+
controls.onChange = () => {
|
|
146
|
+
state.auto = false;
|
|
147
|
+
state.dirty = true;
|
|
148
|
+
if (opts.onInteract) opts.onInteract();
|
|
149
|
+
};
|
|
150
|
+
controls.onClick = (x, y) => {
|
|
151
|
+
if (!state.result || !opts.onPick) return;
|
|
152
|
+
const w = target.clientWidth;
|
|
153
|
+
const h = target.clientHeight;
|
|
154
|
+
if (!w || !h) return;
|
|
155
|
+
const scene = state.result.scene;
|
|
156
|
+
camera.update(w / h, renderer.backend.zeroToOne, scene.bounds);
|
|
157
|
+
const ray = camera.ray(x, y, w, h, renderer.backend.zeroToOne);
|
|
158
|
+
const hit = pick(scene, meshOf, ray.origin, ray.dir, lodVisible(scene));
|
|
159
|
+
opts.onPick(hit ? { ...hit, names: ancestorNames(state.result.doc, hit.uid) } : null);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const io = new IntersectionObserver(([entry]) => {
|
|
163
|
+
state.onScreen = entry.isIntersecting;
|
|
164
|
+
if (state.onScreen) state.dirty = true;
|
|
165
|
+
}, { threshold: 0.02 });
|
|
166
|
+
io.observe(parent || target);
|
|
167
|
+
// Size comes from the observer, so frames never read layout.
|
|
168
|
+
const ro = new ResizeObserver(([entry]) => {
|
|
169
|
+
const box = entry.contentBoxSize && entry.contentBoxSize[0];
|
|
170
|
+
const w = Math.round(box ? box.inlineSize : entry.contentRect.width);
|
|
171
|
+
const h = Math.round(box ? box.blockSize : entry.contentRect.height);
|
|
172
|
+
if (w === state.width && h === state.height) return;
|
|
173
|
+
state.width = w;
|
|
174
|
+
state.height = h;
|
|
175
|
+
target.style.width = `${w}px`;
|
|
176
|
+
target.style.height = `${h}px`;
|
|
177
|
+
state.dirty = true;
|
|
178
|
+
});
|
|
179
|
+
ro.observe(parent || target);
|
|
180
|
+
|
|
181
|
+
const mode = () => (state.result && state.result.scene.controls ? state.result.scene.controls.mode : 'orbit');
|
|
182
|
+
// in scroll and none modes the document's first camera is the view
|
|
183
|
+
const followsDocument = () => mode() !== 'orbit' && state.result && state.result.scene.cameras.length > 0;
|
|
184
|
+
const view = () => ({ width: state.width, height: state.height, dpr: typeof devicePixelRatio === 'number' ? devicePixelRatio : 1 });
|
|
185
|
+
|
|
186
|
+
/** Build, while the browser is idle, the meshes for views sampled along the scroll path. */
|
|
187
|
+
const prefetchScrollPath = () => {
|
|
188
|
+
const gen = state.generation;
|
|
189
|
+
let k = 0;
|
|
190
|
+
const step = () => {
|
|
191
|
+
const res = state.result;
|
|
192
|
+
if (state.disposed || gen !== state.generation || !res || k > PREFETCH_SAMPLES) return;
|
|
193
|
+
if (!state.width || !state.height) return void setTimeout(() => idle(step), 200);
|
|
194
|
+
const p = k / PREFETCH_SAMPLES;
|
|
195
|
+
k++;
|
|
196
|
+
updateDynamic(res.scene, res.graph, state.time, p);
|
|
197
|
+
const cam = followsDocument() ? new Camera().fromSpec(res.scene.cameras[0]) : camera;
|
|
198
|
+
renderer.prepare(cam, view());
|
|
199
|
+
// put the scene back where the page is
|
|
200
|
+
updateDynamic(res.scene, res.graph, state.sceneTime, state.sceneProgress);
|
|
201
|
+
persist.changed(res.cache);
|
|
202
|
+
idle(step);
|
|
203
|
+
};
|
|
204
|
+
idle(step);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const configureScroll = () => {
|
|
208
|
+
const scene = state.result.scene;
|
|
209
|
+
if (!scene.animation.scroll) {
|
|
210
|
+
if (state.timeline) state.timeline.dispose();
|
|
211
|
+
state.timeline = null;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!state.timeline) {
|
|
215
|
+
const section = opts.scroll || document.scrollingElement || document.documentElement;
|
|
216
|
+
state.timeline = new ScrollTimeline(section);
|
|
217
|
+
}
|
|
218
|
+
state.timeline.update(0);
|
|
219
|
+
state.progress = state.timeline.value;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const api = {
|
|
223
|
+
camera,
|
|
224
|
+
renderer,
|
|
225
|
+
controls,
|
|
226
|
+
kind: renderer.kind,
|
|
227
|
+
get time() {
|
|
228
|
+
return state.time;
|
|
229
|
+
},
|
|
230
|
+
set time(v) {
|
|
231
|
+
state.time = v;
|
|
232
|
+
},
|
|
233
|
+
/** Scroll timeline position 0..1; set it to drive scroll animations without a page section. */
|
|
234
|
+
get progress() {
|
|
235
|
+
return state.progress;
|
|
236
|
+
},
|
|
237
|
+
set progress(v) {
|
|
238
|
+
if (state.timeline) {
|
|
239
|
+
state.timeline.dispose();
|
|
240
|
+
state.timeline = null;
|
|
241
|
+
}
|
|
242
|
+
state.progress = Math.min(1, Math.max(0, v));
|
|
243
|
+
},
|
|
244
|
+
get playing() {
|
|
245
|
+
return state.playing;
|
|
246
|
+
},
|
|
247
|
+
set playing(v) {
|
|
248
|
+
state.playing = !!v;
|
|
249
|
+
},
|
|
250
|
+
/** Draw the next frame even if nothing the stage tracks has changed. */
|
|
251
|
+
invalidate,
|
|
252
|
+
get result() {
|
|
253
|
+
return state.result;
|
|
254
|
+
},
|
|
255
|
+
/**
|
|
256
|
+
* Compile and show a document. Returns the compile result; on errors the
|
|
257
|
+
* previous document stays on screen.
|
|
258
|
+
* @param {string} source
|
|
259
|
+
* @param {{keepCamera?: boolean, time?: number}} [showOpts]
|
|
260
|
+
*/
|
|
261
|
+
show(source, showOpts = {}) {
|
|
262
|
+
if (showOpts.time !== undefined) state.time = showOpts.time;
|
|
263
|
+
// Reusing the cache keeps every mesh whose definition did not change
|
|
264
|
+
// (a new wrap or paint only changes materials), so nothing is re-tessellated
|
|
265
|
+
// and the renderer finds those meshes already on the GPU.
|
|
266
|
+
const result = compile(source, { time: state.time, progress: state.progress, cache: state.result ? state.result.cache : undefined });
|
|
267
|
+
if (!result.ok) {
|
|
268
|
+
const msg = result.diagnostics.map((d) => d.format()).join('\n\n');
|
|
269
|
+
if (opts.onError) opts.onError(msg, result);
|
|
270
|
+
else console.error(msg);
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
state.result = result;
|
|
274
|
+
state.generation++;
|
|
275
|
+
renderer.setScene(result.scene, meshOf);
|
|
276
|
+
configureScroll();
|
|
277
|
+
if (state.progress !== 0) updateDynamic(result.scene, result.graph, state.time, state.progress);
|
|
278
|
+
state.sceneTime = state.time;
|
|
279
|
+
state.sceneProgress = state.progress;
|
|
280
|
+
state.dirty = true;
|
|
281
|
+
const spec = result.scene.controls;
|
|
282
|
+
controls.configure(spec, { world });
|
|
283
|
+
if (opts.interactive === false) controls.enabled = false;
|
|
284
|
+
if (spec && spec.mode !== 'orbit') state.auto = false;
|
|
285
|
+
if (!showOpts.keepCamera || followsDocument()) {
|
|
286
|
+
if (result.scene.cameras.length) camera.fromSpec(result.scene.cameras[0]);
|
|
287
|
+
else camera.frame(result.scene.bounds);
|
|
288
|
+
}
|
|
289
|
+
// draw once the stored meshes are in, or without them after a short wait;
|
|
290
|
+
// the scroll path is prepared after that, so it only builds what was not stored
|
|
291
|
+
const gen = state.generation;
|
|
292
|
+
state.holdUntil = performance.now() + RESTORE_WAIT_MS;
|
|
293
|
+
persist.restore(result.cache).finally(() => {
|
|
294
|
+
if (gen !== state.generation) return;
|
|
295
|
+
state.holdUntil = 0;
|
|
296
|
+
state.dirty = true;
|
|
297
|
+
if (result.scene.animation.scroll) prefetchScrollPath();
|
|
298
|
+
});
|
|
299
|
+
return result;
|
|
300
|
+
},
|
|
301
|
+
dispose() {
|
|
302
|
+
state.disposed = true;
|
|
303
|
+
io.disconnect();
|
|
304
|
+
ro.disconnect();
|
|
305
|
+
if (state.timeline) state.timeline.dispose();
|
|
306
|
+
controls.dispose();
|
|
307
|
+
renderer.dispose();
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const frame = (now) => {
|
|
312
|
+
if (state.disposed) return;
|
|
313
|
+
requestAnimationFrame(frame);
|
|
314
|
+
const dt = state.last ? Math.min(0.05, (now - state.last) / 1000) : 0;
|
|
315
|
+
state.last = now;
|
|
316
|
+
if (!state.result || !state.onScreen) return;
|
|
317
|
+
if (!state.width || !state.height) return;
|
|
318
|
+
const res = state.result;
|
|
319
|
+
const scene = res.scene;
|
|
320
|
+
// a scene without clock animations looks the same at every time
|
|
321
|
+
if (state.playing && scene.animation.time) state.time += dt;
|
|
322
|
+
if (state.timeline && state.timeline.update(dt)) state.progress = state.timeline.value;
|
|
323
|
+
if (opts.onFrame) opts.onFrame(dt, api);
|
|
324
|
+
if (state.auto) {
|
|
325
|
+
const prev = controls.snapshot();
|
|
326
|
+
const dx = camera.position[0] - camera.target[0];
|
|
327
|
+
const dz = camera.position[2] - camera.target[2];
|
|
328
|
+
const a = state.orbitSpeed * dt;
|
|
329
|
+
const co = Math.cos(a);
|
|
330
|
+
const si = Math.sin(a);
|
|
331
|
+
camera.position[0] = camera.target[0] + dx * co - dz * si;
|
|
332
|
+
camera.position[2] = camera.target[2] + dx * si + dz * co;
|
|
333
|
+
controls.constrain(prev);
|
|
334
|
+
}
|
|
335
|
+
if (scene.dynamic.length && (state.time !== state.sceneTime || state.progress !== state.sceneProgress)) {
|
|
336
|
+
updateDynamic(scene, res.graph, state.time, state.progress);
|
|
337
|
+
state.sceneTime = state.time;
|
|
338
|
+
state.sceneProgress = state.progress;
|
|
339
|
+
if (followsDocument()) camera.fromSpec(scene.cameras[0]);
|
|
340
|
+
state.dirty = true;
|
|
341
|
+
}
|
|
342
|
+
if (cameraMoved()) state.dirty = true;
|
|
343
|
+
if (!state.dirty || (state.holdUntil && now < state.holdUntil)) return;
|
|
344
|
+
state.dirty = false;
|
|
345
|
+
renderer.render(camera, { ...view(), time: state.time });
|
|
346
|
+
// levels drawn coarser than wanted: refine them on the next frame
|
|
347
|
+
if (renderer.stats.refining) state.dirty = true;
|
|
348
|
+
persist.changed(res.cache);
|
|
349
|
+
};
|
|
350
|
+
requestAnimationFrame(frame);
|
|
351
|
+
return api;
|
|
352
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Backend-independent texture bookkeeping: each distinct texture spec (by key
|
|
2
|
+
// and usage) is generated or downloaded once; backends turn ready pixel data
|
|
3
|
+
// into GPU textures. Failures are reported, never silently ignored.
|
|
4
|
+
|
|
5
|
+
import { texturePixels } from './textures.js';
|
|
6
|
+
|
|
7
|
+
export class TextureCache {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.entries = new Map(); // `${kind}:${key}` -> {status, pixels, error, gpu, spec, kind}
|
|
10
|
+
this.onReady = null; // called when an async texture finishes (request a redraw)
|
|
11
|
+
this.onError = null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Get (and start loading) a texture; returns the entry (status 'pending'|'ready'|'failed'). */
|
|
15
|
+
get(spec, kind) {
|
|
16
|
+
const id = `${kind}:${spec.key}`;
|
|
17
|
+
let e = this.entries.get(id);
|
|
18
|
+
if (e) return e;
|
|
19
|
+
e = { id, spec, kind, status: 'pending', pixels: null, error: null, gpu: null };
|
|
20
|
+
this.entries.set(id, e);
|
|
21
|
+
texturePixels(spec, kind).then(
|
|
22
|
+
(px) => {
|
|
23
|
+
e.pixels = px;
|
|
24
|
+
e.status = 'ready';
|
|
25
|
+
if (this.onReady) this.onReady(e);
|
|
26
|
+
},
|
|
27
|
+
(err) => {
|
|
28
|
+
e.status = 'failed';
|
|
29
|
+
e.error = err;
|
|
30
|
+
if (this.onError) this.onError(e, err);
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
return e;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
clear(destroy) {
|
|
37
|
+
for (const e of this.entries.values()) if (e.gpu && destroy) destroy(e.gpu);
|
|
38
|
+
this.entries.clear();
|
|
39
|
+
}
|
|
40
|
+
}
|