@solidrt/3d 0.0.51 → 0.0.52

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.
@@ -0,0 +1,866 @@
1
+ // The Third Dimension - a GPU demo on @solidrt/3d.
2
+ //
3
+ // One file: the scene, its GLSL and the debug commands. The shader
4
+ // sources are the "Shaders" section below.
5
+ //
6
+ // The scene is two meshes - a stock torusKnot() placed by transform and a
7
+ // stock plane() rotated flat - each drawn by its own shaderMaterial (custom
8
+ // GLSL), depth tested and back-face culled in the scene's own GPU pass,
9
+ // over a backdrop shader drawn as that pass's first entry. Three real
10
+ // directional lights (red, green and blue, evenly spaced in azimuth) plus a
11
+ // hemisphere ambient are scene light NODES: the custom shaders read them
12
+ // through @solidrt/3d's standard light uniforms, so nothing about the
13
+ // shading is a constant baked into the GLSL, and the lights themselves cost
14
+ // no pass. All three CAST (castShadow), which a scene allows up to
15
+ // MAX_SHADOWS = MAX_LIGHTS: each owns a depth texture drawn from its own
16
+ // orthographic frustum, and both custom fragments read the whole set back
17
+ // with SHADOW_SLOTS, SHADOW and SHADOW_LOOKUP from @solidrt/3d/glsl - the
18
+ // same three constants the lit material composes. A shadow is therefore
19
+ // the COMPLEMENT of the light it blocks - red's is cyan - where two cross
20
+ // only the third light survives and the colour goes pure, and where all
21
+ // three cross the floor falls to the ambient alone. The rig hangs off one
22
+ // Group that turns slowly, so the three shadows sweep and cross from a
23
+ // single setTransform per frame.
24
+ //
25
+ // The window is a split of THREE renderings of that one scene: the scene's
26
+ // own target taking the larger share, plus two scene.createView panels
27
+ // beside it - a side view, where the knot's weave reads because it lies
28
+ // flat, and straight down from above, where the three shadows cross. The
29
+ // split follows the window: the two panels stack down the right in
30
+ // landscape and sit side by side along the bottom in portrait. All three are draggable
31
+ // orbits with poses of their own. A view shares the scene's geometry, materials, lights and
32
+ // shadow maps; it is another target, another camera and one entry per mesh,
33
+ // and one core flush writes every entry's world matrix, so two more panels
34
+ // cost no per-frame JS. All three keep the scene's perspective projection.
35
+ // An orbit
36
+ // camera (createOrbitCamera) per panel owns its pose: drag to rotate, wheel
37
+ // or pinch to zoom, and an auto-orbit that pauses while dragging. A click or
38
+ // tap pauses the panel it lands on and only that one; space is the keyboard
39
+ // form and is global, the large view leading and the small ones taking its
40
+ // new state. The light rig follows the large view. Per-frame JS cost is
41
+ // constant no matter how many triangles the passes cover - one update(dt),
42
+ // one setTransform on the rig, and the scene's own shared camera writes
43
+ // when a pose changed.
44
+
45
+ import {
46
+ capabilities,
47
+ createEffect,
48
+ createMemo,
49
+ createSignal,
50
+ env,
51
+ flush,
52
+ onFrame,
53
+ render,
54
+ safeArea,
55
+ untrack,
56
+ windowSize,
57
+ } from "@solidrt/core"
58
+ import type { PointerEvent, WheelEvent } from "@solidrt/core"
59
+ import { glsl, limits } from "@solidrt/core/gpu"
60
+ import {
61
+ add,
62
+ createDirectionalLight,
63
+ createGroup,
64
+ createHemisphereLight,
65
+ createMesh,
66
+ createOrbitCamera,
67
+ createScene,
68
+ plane,
69
+ setCastShadow,
70
+ setLight,
71
+ setTransform,
72
+ shaderMaterial,
73
+ torusKnot,
74
+ STANDARD_FLOATS,
75
+ } from "@solidrt/3d"
76
+ import type { DirectionalLightNode, OrbitCamera, SceneNode, Vec3 } from "@solidrt/3d"
77
+ import { BLINN_SPECULAR, HEMISPHERE, LAMBERT, LIT_VERTEX, MAX_LIGHTS, SHADOW, SHADOW_LOOKUP, SHADOW_SLOTS } from "@solidrt/3d/glsl"
78
+ import { registerDebug } from "srt:dev"
79
+
80
+ const KNOT_P = 2
81
+ const KNOT_Q = 3
82
+ /** Where the knot stands, and the orbit camera's pivot. */
83
+ const KNOT_CENTER: Vec3 = [0, 1.4, 0]
84
+ const FLOOR_SIZE = 36 // world units across; GROUND_FRAGMENT interpolates it
85
+ const FOV = 0.85 // vertical field of view, radians (the scene camera speaks degrees)
86
+ const ORBIT_PERIOD = 68 // seconds for one full revolution
87
+ const MIN_DISTANCE = 2.6
88
+ const MAX_DISTANCE = 14
89
+ // Wheel zoom is eased by the app, not taken from orbit.handlers: a notch
90
+ // retargets the distance and the camera glides there over the next few
91
+ // frames, so a scroll reads as one continuous push instead of a staircase.
92
+ // The exponent per wheel-delta unit matches the library's own sensitivity.
93
+ const WHEEL_ZOOM = 0.0015
94
+ const ZOOM_EASE = 9 // e-foldings per second toward the pending distance
95
+ const ZOOM_EPSILON = 0.0005 // world units; inside this the glide lands and stops
96
+ // Lowest the eye may sit, world units. The ground is one back-face-culled
97
+ // quad, so the picture falls apart the instant the camera dips under y=0 -
98
+ // the floor just vanishes. A fixed elevation clamp cannot hold that line:
99
+ // eye height is target.y + distance * sin(elevation), so an elevation that
100
+ // sits comfortably high up close digs under the floor at full zoom-out.
101
+ // Hence a floor on the eye instead, turned back into an elevation clamp
102
+ // against the distance of the moment.
103
+ const EYE_MIN_Y = 0.35
104
+
105
+ // Three real directional lights, evenly spaced in azimuth and tilted down by
106
+ // the same angle: a triangular rig, so the knot is lit from every side and
107
+ // no face falls to ambient alone. These are scene light NODES, not constants
108
+ // in a shader - the materials read them through the standard uniform set, so
109
+ // setLight/setTransform on one of these re-shades the scene.
110
+ const LIGHT_ELEVATION = 0.62 // radians above the horizon
111
+ // Pure primaries, one per corner of the triangle. Because all three sit at
112
+ // the same elevation, a FLAT surface takes the same Lambert term from each,
113
+ // so they sum back to neutral on the floor - the colour separation is a
114
+ // curvature effect, and the knot is what shows it.
115
+ const LIGHT_TINTS: Vec3[] = [
116
+ [1, 0, 0],
117
+ [0, 1, 0],
118
+ [0, 0, 1],
119
+ ]
120
+ const LIGHT_INTENSITY = 0.72
121
+ // Where the triangle starts, so a light is not aimed straight down the
122
+ // camera's opening azimuth.
123
+ const LIGHT_PHASE = 0.6
124
+ // How far back up its own ray each light NODE sits. A directional light
125
+ // needs no position to shine, but a casting one renders its shadow map from
126
+ // a camera placed at the node's world position, so it has to stand off the
127
+ // scene. They are one rig, so all three get the same placement.
128
+ const LIGHT_DISTANCE = 10
129
+ // Seconds for one full turn of the light rig - slower than the camera
130
+ // orbit, so the two motions stay legible as two.
131
+ const RIG_PERIOD = 120
132
+ // Every light casts. A scene takes MAX_SHADOWS = MAX_LIGHTS casting
133
+ // directional lights, so all three corners of the triangle get a map and a
134
+ // depth pass of their own, and each shadow is the COMPLEMENT of the light
135
+ // it blocks. The `shadow` debug command switches individual casters off to
136
+ // take the effect apart.
137
+ // The light frustum, world units either side of the light ray. The knot's
138
+ // bounding sphere is radius 2.18 (torusKnot's radius * 1.5, plus tube), and
139
+ // the frustum is a prism along the light direction, so a box that holds the
140
+ // caster also holds everywhere its shadow can land. Tight matters: the
141
+ // map's texels spread over this box, and 5.2 world units across 1024 of
142
+ // them is about 0.005 each.
143
+ const SHADOW_EXTENT = 2.6
144
+ const SHADOW_MAP = 1024
145
+ // Depth range along the light ray: the caster sits at LIGHT_DISTANCE and
146
+ // its shadow reaches the floor a few units past it.
147
+ const SHADOW_NEAR = 2
148
+ const SHADOW_FAR = 22
149
+ // The split: one large panel taking SPLIT of the window's long axis, the
150
+ // two others sharing what is left across the short one - stacked down the
151
+ // right in landscape, side by side along the bottom in portrait. They share
152
+ // edges with no gap: the rounding leftovers go to the two small panels, so
153
+ // the three tile the window exactly whichever way it turns.
154
+ const SPLIT = 0.66
155
+ // Where the lower-right panel opens: all but straight down (1.55 is the
156
+ // library's own pole guard), far enough back that the vertical FOV covers
157
+ // the whole shadow spread, about +-4 world units at the floor.
158
+ const TOP_DOWN_ELEVATION = 1.5
159
+ const TOP_DOWN_DISTANCE = 11.6
160
+ // The hero panel's opening azimuth. The side panel is placed RELATIVE to it
161
+ // rather than in world terms: every panel sweeps at the same rate, so what
162
+ // stays visible between two of them is how far apart they sit, never where
163
+ // either happens to point at t = 0.
164
+ const HERO_AZIMUTH = 0.9
165
+ // Where the upper-right panel opens, tuned on screen. The knot is a flat
166
+ // disc in the xz plane, so a side view catches it edge-on and its weave -
167
+ // which loop passes over which - reads there and nowhere else. The eye sits
168
+ // just BELOW the knot's centre (about y = 0.73 at this distance) looking
169
+ // slightly up at it, which stands the knot clear of the floor instead of
170
+ // laying it into the checker. Well inside the floor clamp: at this distance
171
+ // holdAboveFloor would not bite until about -0.124.
172
+ const SIDE_OFFSET = -3.096 // radians round from the hero
173
+ const SIDE_AZIMUTH = HERO_AZIMUTH + SIDE_OFFSET
174
+ const SIDE_ELEVATION = -0.079
175
+ const SIDE_DISTANCE = 8.5
176
+ // How far the two right-hand panels may be pushed and pulled.
177
+ const PANEL_MIN_DISTANCE = 3.5
178
+ const PANEL_MAX_DISTANCE = 30
179
+ // The extra views get no background entry of their own (a view mirrors the
180
+ // scene's meshes, not its backdrop), so this stands in for the backdrop
181
+ // shader's outer tone where the ground has faded away.
182
+ const VIEW_CLEAR: [number, number, number, number] = [0.02, 0.028, 0.048, 1]
183
+ // Floor under the display's own scale: on a 1x display there is no downscale
184
+ // to soften polygon edges (targets have no MSAA), so render the scene larger
185
+ // than the box it is drawn into and let the texture filter resolve it.
186
+ const MIN_RENDER_SCALE = 1.5
187
+
188
+ // ------ Shaders ------
189
+ //
190
+ // GLSL ES 3.00 sources for the scene. None declares `#version`, so the
191
+ // runtime injects its own preamble: `fragColor` and `iResolution` for the
192
+ // fragment stages, plus `vUV` for the fragment-only backdrop (a pipeline's
193
+ // varyings are its own vertex stage's job). Every other uniform below is
194
+ // part of @solidrt/3d's standard set, opt-in by declare-and-use.
195
+ //
196
+ // The knot and the ground are two @solidrt/3d shaderMaterials sharing one
197
+ // vertex stage - LIT_VERTEX from @solidrt/3d/glsl, the package's standard
198
+ // stage: it transforms by the per-mesh `uModel` and the target-shared
199
+ // `uViewProj`, and hands the fragments world position (vWorldPos), world
200
+ // normal (vNormal, through mat3(uNormal)) and UV (vUv). Lighting is
201
+ // therefore world-space throughout, and `uCamPos` - the camera's world
202
+ // position, which the scene writes once per camera move beside uViewProj -
203
+ // is the view vector's other end. Lighting comes from the scene's real
204
+ // light nodes through the standard uniform set (see LIGHTING below), so
205
+ // there are no app-written uniforms and no baked-in light directions: the
206
+ // meshes may be placed by any transform without the shaders caring, and
207
+ // moving a light node re-shades everything.
208
+
209
+ const LIGHTING = glsl`
210
+ uniform vec3 uHemiSky;
211
+ uniform vec3 uHemiGround;
212
+ uniform int uLightCount;
213
+ uniform vec3 uLightDir[${MAX_LIGHTS}];
214
+ uniform vec3 uLightColor[${MAX_LIGHTS}];
215
+
216
+ // The shadow set: one slot per directional light, all of it written by
217
+ // the scene at target level. uShadowMap<i> is light i's depth map (a
218
+ // white texel when it does not cast), uShadowMatrix[i] the light-space
219
+ // view-projection that rendered it, uShadowCast[i] whether it casts, and
220
+ // the two bias knobs are per light as well.
221
+ ${SHADOW_SLOTS}
222
+
223
+ ${HEMISPHERE}
224
+ ${LAMBERT}
225
+ ${BLINN_SPECULAR}
226
+ ${SHADOW}
227
+ // lightShadow(i, worldPos, n): light i's factor, 1 when it does not
228
+ // cast - the same lookup the lit material composes.
229
+ ${SHADOW_LOOKUP}
230
+ `
231
+
232
+ /**
233
+ * Knot fragment stage: Blinn-Phong (key + fill + rim + specular) over a
234
+ * cosine palette swept along the knot.
235
+ */
236
+ let KNOT_FRAGMENT = glsl`
237
+ in vec3 vWorldPos;
238
+ in vec3 vNormal;
239
+ in vec2 vUv;
240
+
241
+ uniform vec3 uCamPos;
242
+
243
+ ${LIGHTING}
244
+
245
+ const float TAU = 6.28318530718;
246
+
247
+ // Cosine gradient swept along the knot. The channel phases are a third of a
248
+ // cycle apart, so no value of t lands on three equal channels - the sweep
249
+ // never passes through grey.
250
+ vec3 palette(float t) {
251
+ vec3 hue = vec3(0.50) + vec3(0.48) * cos(TAU * (t + vec3(0.00, 0.33, 0.67)));
252
+ // Pulled toward a deep cool tone rather than toward white: the hue sweep
253
+ // stays rich instead of going pastel, and leaves headroom for the
254
+ // specular highlight to be the brightest thing on the surface.
255
+ return mix(hue, vec3(0.16, 0.20, 0.30), 0.34);
256
+ }
257
+
258
+ void main() {
259
+ vec3 view = normalize(uCamPos - vWorldPos);
260
+ vec3 n = normalize(vNormal);
261
+
262
+ vec3 base = palette(vUv.x + 0.05 * sin(vUv.y * TAU * 3.0));
263
+ // Faint bands around the tube, so the surface reads as a swept ring.
264
+ float band = smoothstep(0.30, 0.50, abs(fract(vUv.x * 64.0) - 0.5));
265
+ base *= mix(0.82, 1.0, band);
266
+
267
+ vec3 light = hemisphere(n, uHemiSky, uHemiGround);
268
+ vec3 spec = vec3(0.0);
269
+ for (int i = 0; i < ${MAX_LIGHTS}; i++) {
270
+ if (i >= uLightCount) break;
271
+ vec3 l = uLightDir[i];
272
+ // The knot draws into every map, so it also self-shadows: a stretch
273
+ // of tube in the lee of another loses that light and keeps the rest,
274
+ // which is where the colour separation gets its hardest edge.
275
+ float s = lightShadow(i, vWorldPos, n);
276
+ light += uLightColor[i] * lambert(n, l) * s;
277
+ spec += uLightColor[i] * blinnSpecular(n, view, l, 64.0) * s;
278
+ }
279
+
280
+ vec3 lit = base * light + spec * 0.45;
281
+
282
+ // Fresnel rim tinted by the surface's own color, so grazing angles
283
+ // brighten the silhouette instead of bleaching it toward white. A
284
+ // view-dependent term of the shading model, not a stand-in light.
285
+ float rim = pow(1.0 - max(dot(n, view), 0.0), 3.5);
286
+ lit += mix(vec3(0.25, 0.45, 0.85), base, 0.5) * rim * 0.30;
287
+
288
+ fragColor = vec4(pow(clamp(lit, 0.0, 1.0), vec3(0.92)), 1.0);
289
+ }
290
+ `
291
+
292
+ /**
293
+ * Ground fragment stage: a distance-faded checkerboard, lit by the scene's
294
+ * lights and the shadow map, over the plane's own UVs scaled to its world
295
+ * footprint (FLOOR_SIZE, interpolated), so the mesh can be a stock plane()
296
+ * under any transform. Output is premultiplied
297
+ * alpha - the ground fades to fully transparent so the scene's background
298
+ * entry shows through it. That fade is why the material is `transparent`.
299
+ */
300
+ let GROUND_FRAGMENT = glsl`
301
+ in vec2 vUv;
302
+ in vec3 vNormal;
303
+ in vec3 vWorldPos;
304
+
305
+ ${LIGHTING}
306
+
307
+ // World units per square.
308
+ const float TILE = 1.15;
309
+
310
+ /**
311
+ * Box-filtered checkerboard: the ANALYTIC average of the checker over the
312
+ * pixel's footprint, not a point sample. A plain step() checker is the
313
+ * worst case for aliasing - at grazing angles one pixel spans many squares
314
+ * and point sampling turns them into crawling moire. Integrating the
315
+ * square wave and differencing that integral across the footprint (w, from
316
+ * the screen-space derivatives) gives each pixel the exact mean instead,
317
+ * so the pattern converges smoothly to flat grey as it recedes and there
318
+ * is nothing left to alias. Inigo Quilez's checkersGradBox, on fwidth.
319
+ */
320
+ float checker(vec2 p) {
321
+ vec2 w = fwidth(p) + 0.001;
322
+ vec2 i = 2.0 * (abs(fract((p - 0.5 * w) * 0.5) - 0.5)
323
+ - abs(fract((p + 0.5 * w) * 0.5) - 0.5)) / w;
324
+ return 0.5 - 0.5 * i.x * i.y;
325
+ }
326
+
327
+ void main() {
328
+ vec2 p = (vUv - 0.5) * ${FLOOR_SIZE.toFixed(1)};
329
+ float r = length(p);
330
+ float fade = 1.0 - smoothstep(6.0, 17.0, r);
331
+
332
+ // Albedo, not final colour: the light term below multiplies it.
333
+ vec3 tile = mix(vec3(0.16, 0.18, 0.22), vec3(0.52, 0.55, 0.60), checker(p / TILE));
334
+
335
+ // The same real lights the knot uses. The floor's normal is constant, so
336
+ // every directional term is flat across the whole plane and the three
337
+ // tints sum back to neutral - until the maps take them away one at a
338
+ // time. That is the picture here: three shadows, each keeping the two
339
+ // lights it does not block, crossing into pure primaries where two
340
+ // overlap and into the ambient alone where all three do. A flat plane
341
+ // is the only place the rig's tints separate this cleanly.
342
+ vec3 n = normalize(vNormal);
343
+ vec3 light = hemisphere(n, uHemiSky, uHemiGround);
344
+ for (int i = 0; i < ${MAX_LIGHTS}; i++) {
345
+ if (i >= uLightCount) break;
346
+ light += uLightColor[i] * lambert(n, uLightDir[i]) * lightShadow(i, vWorldPos, n);
347
+ }
348
+ tile *= light;
349
+
350
+ // Premultiplied, alpha being the distance fade: the floor is a solid
351
+ // surface that dissolves into the background at the rim, so the shadow
352
+ // simply darkens the tiles rather than contributing coverage of its own.
353
+ fragColor = vec4(tile * fade, fade);
354
+ }
355
+ `
356
+
357
+ /**
358
+ * Backdrop: a static radial gradient with a touch of hash grain so the ramp
359
+ * does not band. Handed to createScene as `background`, so it is the scene
360
+ * pass's FIRST entry - an attributeless fullscreen triangle with depth off,
361
+ * redrawn with the pass rather than cached in a texture of its own. It takes
362
+ * the shader-target contract (vUV, iResolution, fragColor) either way, so
363
+ * this source is unchanged from when it fed createShaderTexture.
364
+ */
365
+ let BACKDROP_FRAGMENT = glsl`
366
+ void main() {
367
+ float d = distance(vUV, vec2(0.5, 0.40));
368
+ vec3 near = vec3(0.075, 0.105, 0.170);
369
+ vec3 far = vec3(0.020, 0.028, 0.048);
370
+ vec3 col = mix(near, far, smoothstep(0.05, 0.95, d));
371
+ float n = fract(sin(dot(vUV * iResolution, vec2(12.9898, 78.233))) * 43758.5453);
372
+ col += (n - 0.5) * 0.010;
373
+ fragColor = vec4(col, 1.0);
374
+ }
375
+ `
376
+
377
+ // Pixels per logical unit the scene renders at; 0 means "follow the display".
378
+ let [renderScale, setRenderScale] = createSignal(0)
379
+ // How many lights currently cast: the subtitle reads it, the `shadow`
380
+ // debug command writes it.
381
+ let [casters, setCasters] = createSignal(LIGHT_TINTS.length)
382
+ // Assigned in App; the debug commands below only run once the app is up.
383
+ // One orbit camera per panel: the large one on the left, then the two on
384
+ // the right. Only the left one auto-orbits.
385
+ let orbit!: OrbitCamera
386
+ let topOrbit!: OrbitCamera
387
+ let bottomOrbit!: OrbitCamera
388
+ let lights: DirectionalLightNode[] = []
389
+ // The light rig and where it has turned to. Module scope so the `rig` debug
390
+ // command can park it: the spin pauses with the orbit, so a parked pose and
391
+ // a parked rig together are one repeatable frame.
392
+ let rig!: SceneNode
393
+ let rigAngle = 0
394
+ // A panel as input and the frame loop see it: its camera, the distance range
395
+ // its wheel may steer within, and the eased zoom in flight (null when there
396
+ // is none). Assigned in App, in left-then-right order.
397
+ type PanelCamera = { cam: OrbitCamera; minDistance: number; maxDistance: number; zoom: number | null }
398
+ let cameras: PanelCamera[] = []
399
+
400
+ // A tap toggles the auto-orbit, so tablets have a pause too. A tap is one
401
+ // pointer that goes down and up within TAP_SLOP and TAP_MS without a second
402
+ // finger joining: crossing the slop is what makes an orbit drag, so the two
403
+ // never overlap. Mouse clicks qualify as taps as well.
404
+ const TAP_SLOP = 8
405
+ const TAP_MS = 300
406
+ let tap: { id: number; x: number; y: number; at: number } | null = null
407
+
408
+ let clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
409
+
410
+ // The keyboard's pause, and the only global one: the LARGE view leads and
411
+ // the two small ones are handed its new state, so one key puts the whole
412
+ // window in step and a paused scene is a still frame end to end. A click or
413
+ // tap is local to the panel it lands on (see panelInput). The large view's
414
+ // signal is what the hint text reads, and what the light rig follows.
415
+ let setAllOrbiting = (orbiting: boolean) => {
416
+ orbit.set({ orbiting })
417
+ topOrbit.set({ orbiting })
418
+ bottomOrbit.set({ orbiting })
419
+ }
420
+
421
+ // Hold one camera's eye above the floor (see the EYE_MIN_Y note): the
422
+ // elevation floor is a function of the distance, so it has to be re-derived
423
+ // every frame rather than fixed once as a minElevation.
424
+ let holdAboveFloor = (cam: OrbitCamera) => {
425
+ let pose = cam.pose()
426
+ let minElevation = Math.asin(clamp((EYE_MIN_Y - KNOT_CENTER[1]) / pose.distance, -1, 1))
427
+ if (pose.elevation < minElevation) cam.set({ elevation: minElevation })
428
+ }
429
+
430
+ function App() {
431
+ let knotGeometry = torusKnot({
432
+ radius: 1.25,
433
+ tube: 0.3,
434
+ tubularSegments: 320,
435
+ radialSegments: 26,
436
+ p: KNOT_P,
437
+ q: KNOT_Q,
438
+ label: "knot",
439
+ })
440
+ let groundGeometry = plane({ width: FLOOR_SIZE, height: FLOOR_SIZE, label: "ground" })
441
+ let triangles = (knotGeometry.indices.length + groundGeometry.indices.length) / 3
442
+ let vertexCount = (knotGeometry.vertices.length + groundGeometry.vertices.length) / STANDARD_FLOATS
443
+ let bytes =
444
+ knotGeometry.vertices.byteLength +
445
+ knotGeometry.indices.byteLength +
446
+ groundGeometry.vertices.byteLength +
447
+ groundGeometry.indices.byteLength
448
+ console.log(
449
+ `scene: ${triangles} triangles, ${vertexCount} vertices + ` +
450
+ `${knotGeometry.indices.length + groundGeometry.indices.length} u16 indices, ` +
451
+ `${(bytes / 1024 / 1024).toFixed(2)} MiB`,
452
+ )
453
+
454
+ // The three panel boxes in logical units, tiling the window exactly: the
455
+ // leftovers of the rounding go to the two small panels, so no seam ever
456
+ // opens up at the far edge. `top` and `bottom` name the two VIEWS, not
457
+ // where they land - in portrait they are the bottom-left and bottom-right
458
+ // of the row under the hero panel.
459
+ let panels = createMemo(() => {
460
+ let size = windowSize()
461
+ // x/y/w/h is the DETACHED box vocabulary (d-* only) - a d-texture takes
462
+ // no layout width/height and would silently fall back to the inherited
463
+ // window box, drawing all three panels on top of each other.
464
+ if (size.height > size.width) {
465
+ let hero = Math.round(size.height * SPLIT)
466
+ let rest = size.height - hero
467
+ let half = Math.round(size.width / 2)
468
+ return {
469
+ main: { x: 0, y: 0, w: size.width, h: hero },
470
+ top: { x: 0, y: hero, w: half, h: rest },
471
+ bottom: { x: half, y: hero, w: size.width - half, h: rest },
472
+ }
473
+ }
474
+ let left = Math.round(size.width * SPLIT)
475
+ let right = size.width - left
476
+ let top = Math.round(size.height / 2)
477
+ return {
478
+ main: { x: 0, y: 0, w: left, h: size.height },
479
+ top: { x: left, y: 0, w: right, h: top },
480
+ bottom: { x: left, y: top, w: right, h: size.height - top },
481
+ }
482
+ })
483
+ // Render at device pixels so the knot's silhouette stays crisp on hi-DPI,
484
+ // clamped to what the device can actually allocate. Every panel is its own
485
+ // target now, each sized from its own box - a small panel costs a small
486
+ // texture, and the three together cover about the pixels one full-window
487
+ // view used to.
488
+ let targetSize = createMemo(() => {
489
+ let box = panels()
490
+ let scale = renderScale() || Math.max(env.displayScale, MIN_RENDER_SCALE)
491
+ let pixels = (b: { w: number; h: number }) => ({
492
+ width: clamp(Math.round(b.w * scale), 1, limits.maxTextureSize),
493
+ height: clamp(Math.round(b.h * scale), 1, limits.maxTextureSize),
494
+ })
495
+ return { main: pixels(box.main), top: pixels(box.top), bottom: pixels(box.bottom) }
496
+ })
497
+ let initial = untrack(targetSize)
498
+
499
+ // The backdrop is the scene pass's first entry (depth off, covering the
500
+ // target), so there is no second texture layer and no resize plumbing of
501
+ // its own - the ground's fade blends straight onto it.
502
+ let scene = createScene(initial.main.width, initial.main.height, {
503
+ label: "scene",
504
+ background: BACKDROP_FRAGMENT,
505
+ })
506
+ scene.setCamera({ fov: (FOV * 180) / Math.PI, near: 0.1, far: 80 })
507
+ orbit = createOrbitCamera(scene, {
508
+ target: KNOT_CENTER,
509
+ azimuth: HERO_AZIMUTH,
510
+ elevation: 0.34,
511
+ distance: 5.4,
512
+ minDistance: MIN_DISTANCE,
513
+ maxDistance: MAX_DISTANCE,
514
+ minElevation: -0.15,
515
+ maxElevation: 1.35,
516
+ orbitSpeed: (Math.PI * 2) / ORBIT_PERIOD,
517
+ })
518
+ // The rig: direction is the way each light SHINES (down and inward), which
519
+ // the scene negates into the uLightDir the shaders read. The hemisphere
520
+ // light is the floor under all of it: with three shadows crossing, the
521
+ // patch where all three lights are blocked would otherwise be pure black,
522
+ // and this is what it falls to instead.
523
+ add(
524
+ scene.root,
525
+ createHemisphereLight({ sky: [0.42, 0.48, 0.60], ground: [0.14, 0.15, 0.19], intensity: 1 }),
526
+ )
527
+ // The three lights hang off ONE Group, so the slow turn in the frame loop
528
+ // stays a single setTransform however many corners the triangle grows. A
529
+ // parent rotation carries both halves of a light: the direction it shines
530
+ // and the position its shadow camera is placed at.
531
+ rig = createGroup()
532
+ add(scene.root, rig)
533
+ lights = []
534
+ for (let i = 0; i < LIGHT_TINTS.length; i++) {
535
+ let azimuth = LIGHT_PHASE + (i / LIGHT_TINTS.length) * Math.PI * 2
536
+ let horizontal = Math.cos(LIGHT_ELEVATION)
537
+ let direction: Vec3 = [
538
+ -horizontal * Math.cos(azimuth),
539
+ -Math.sin(LIGHT_ELEVATION),
540
+ -horizontal * Math.sin(azimuth),
541
+ ]
542
+ let light = createDirectionalLight({
543
+ direction,
544
+ color: LIGHT_TINTS[i]!,
545
+ intensity: LIGHT_INTENSITY,
546
+ castShadow: true,
547
+ shadow: {
548
+ mapSize: SHADOW_MAP,
549
+ // Along the receiving surface's own normal, the knob to reach for
550
+ // first. The depth pass culls FRONT faces, so a closed caster like
551
+ // the knot needs no depth bias at all on top of it.
552
+ normalBias: 0.02,
553
+ camera: {
554
+ left: -SHADOW_EXTENT,
555
+ right: SHADOW_EXTENT,
556
+ top: SHADOW_EXTENT,
557
+ bottom: -SHADOW_EXTENT,
558
+ near: SHADOW_NEAR,
559
+ far: SHADOW_FAR,
560
+ },
561
+ },
562
+ })
563
+ // Each casting light's shadow camera sits AT its node's world position
564
+ // (Three's rule), so back the light up its own ray from the knot: at
565
+ // the origin it would render its map from inside the caster and shadow
566
+ // nothing.
567
+ setTransform(light, {
568
+ position: [
569
+ KNOT_CENTER[0] - direction[0] * LIGHT_DISTANCE,
570
+ KNOT_CENTER[1] - direction[1] * LIGHT_DISTANCE,
571
+ KNOT_CENTER[2] - direction[2] * LIGHT_DISTANCE,
572
+ ],
573
+ })
574
+ add(rig, light)
575
+ lights.push(light)
576
+ }
577
+
578
+ // The ground writes premultiplied alpha and fades to clear, so it is a
579
+ // transparent material: the scene draws it after the opaque knot with
580
+ // depth writes off, and the depth test keeps it behind the knot's pixels.
581
+ // Both materials default to cull "back", so the ground quad would vanish if
582
+ // the camera dipped below the floor plane - which the EYE_MIN_Y clamp in
583
+ // the frame loop is there to prevent.
584
+ let ground = createMesh(
585
+ groundGeometry,
586
+ shaderMaterial({
587
+ vertex: LIT_VERTEX,
588
+ fragment: GROUND_FRAGMENT,
589
+ transparent: true,
590
+ label: "ground",
591
+ }),
592
+ )
593
+ let knot = createMesh(
594
+ knotGeometry,
595
+ shaderMaterial({ vertex: LIT_VERTEX, fragment: KNOT_FRAGMENT, label: "knot" }),
596
+ )
597
+ add(scene.root, ground)
598
+ add(scene.root, knot)
599
+ setTransform(ground, { rotation: [-Math.PI / 2, 0, 0] })
600
+ setTransform(knot, { position: KNOT_CENTER })
601
+ // Only the knot casts. The ground is one single-sided quad and the depth
602
+ // pass culls front faces, so it would draw nothing into the map anyway -
603
+ // and its entry in the shadow view would cost a draw for nothing.
604
+ setCastShadow(knot, true)
605
+
606
+ // The three renderings of ONE scene. A view shares the scene's geometry,
607
+ // materials, lights and shadow maps - it is another target, another camera
608
+ // and one entry per mesh, and the core's flush writes every entry's world
609
+ // matrix at once, so the extra panels cost the app nothing per frame.
610
+ let topView = scene.createView({
611
+ width: initial.top.width,
612
+ height: initial.top.height,
613
+ clearColor: VIEW_CLEAR,
614
+ label: "top-right",
615
+ })
616
+ topView.setCamera({ fov: (FOV * 180) / Math.PI, near: 0.1, far: 80 })
617
+ let bottomView = scene.createView({
618
+ width: initial.bottom.width,
619
+ height: initial.bottom.height,
620
+ clearColor: VIEW_CLEAR,
621
+ label: "bottom-right",
622
+ })
623
+ bottomView.setCamera({ fov: (FOV * 180) / Math.PI, near: 0.1, far: 80 })
624
+ // A view has the same setCamera as a scene, which is all an orbit camera
625
+ // ever touches, so each panel gets a real one: drag, pinch and wheel, and
626
+ // a pose of its own. The upper panel opens from the side and the lower one
627
+ // all but straight down - the two orthogonal readings a single orbiting
628
+ // hero shot keeps sweeping past. All three auto-orbit at the same rate, so
629
+ // they hold their phase offsets and read as three cameras on one
630
+ // turntable. Each pauses on its own click; space stops all three.
631
+ topOrbit = createOrbitCamera(topView, {
632
+ target: KNOT_CENTER,
633
+ azimuth: SIDE_AZIMUTH,
634
+ elevation: SIDE_ELEVATION,
635
+ distance: SIDE_DISTANCE,
636
+ minDistance: PANEL_MIN_DISTANCE,
637
+ maxDistance: PANEL_MAX_DISTANCE,
638
+ minElevation: -0.15,
639
+ maxElevation: 1.55,
640
+ orbitSpeed: (Math.PI * 2) / ORBIT_PERIOD,
641
+ })
642
+ bottomOrbit = createOrbitCamera(bottomView, {
643
+ target: KNOT_CENTER,
644
+ azimuth: HERO_AZIMUTH,
645
+ elevation: TOP_DOWN_ELEVATION,
646
+ distance: TOP_DOWN_DISTANCE,
647
+ minDistance: PANEL_MIN_DISTANCE,
648
+ maxDistance: PANEL_MAX_DISTANCE,
649
+ minElevation: -0.15,
650
+ maxElevation: 1.55,
651
+ orbitSpeed: (Math.PI * 2) / ORBIT_PERIOD,
652
+ })
653
+ cameras = [
654
+ { cam: orbit, minDistance: MIN_DISTANCE, maxDistance: MAX_DISTANCE, zoom: null },
655
+ { cam: topOrbit, minDistance: PANEL_MIN_DISTANCE, maxDistance: PANEL_MAX_DISTANCE, zoom: null },
656
+ { cam: bottomOrbit, minDistance: PANEL_MIN_DISTANCE, maxDistance: PANEL_MAX_DISTANCE, zoom: null },
657
+ ]
658
+
659
+ // Every target follows its own panel; the ids are stable across a resize,
660
+ // so the <d-texture src> bindings and the owner-scoped auto-free keep
661
+ // working.
662
+ createEffect(targetSize, size => {
663
+ scene.setSize(size.main.width, size.main.height)
664
+ topView.setSize(size.top.width, size.top.height)
665
+ bottomView.setSize(size.bottom.width, size.bottom.height)
666
+ })
667
+
668
+ // One panel's input, spread onto its own texture leaf: that camera's drag
669
+ // and pinch, an eased wheel, and the tap that toggles the global pause.
670
+ // Which panel a gesture belongs to is the ENGINE's answer, not the app's -
671
+ // the runtime freezes each pointer's hit path at the down and delivers
672
+ // every later event along it, so a drag that runs off its panel keeps
673
+ // arriving here with no capture to arrange, and two fingers on one panel
674
+ // both land on it, which is what makes the pinch work.
675
+ let panelInput = (panel: PanelCamera) => ({
676
+ onPointerDown: (e: PointerEvent) => {
677
+ // A pinch drives distance directly; drop this panel's glide so the two
678
+ // do not fight over the pose.
679
+ panel.zoom = null
680
+ tap = tap === null ? { id: e.pointerId, x: e.clientX, y: e.clientY, at: performance.now() } : null
681
+ panel.cam.handlers.onPointerDown(e)
682
+ },
683
+ onPointerMove: (e: PointerEvent) => panel.cam.handlers.onPointerMove(e),
684
+ onPointerUp: (e: PointerEvent) => {
685
+ panel.cam.handlers.onPointerUp(e)
686
+ if (tap === null || tap.id !== e.pointerId) return
687
+ let moved = Math.hypot(e.clientX - tap.x, e.clientY - tap.y)
688
+ let held = performance.now() - tap.at
689
+ tap = null
690
+ // A tap pauses THIS panel and nothing else - the other two keep
691
+ // sweeping, so one view can be held still to study while the rest of
692
+ // the window carries on. Space is the global form.
693
+ if (moved < TAP_SLOP && held < TAP_MS) panel.cam.set({ orbiting: !panel.cam.orbiting() })
694
+ },
695
+ // A notch retargets this panel's distance and the camera glides there
696
+ // over the next few frames, so a scroll reads as one continuous push
697
+ // instead of a staircase. Compounding from the PENDING distance rather
698
+ // than the current one, so a fast scroll accumulates its notches instead
699
+ // of each one restarting the glide from wherever the last had reached.
700
+ onWheel: (e: WheelEvent) => {
701
+ let from = panel.zoom ?? panel.cam.pose().distance
702
+ panel.zoom = clamp(from * Math.exp(e.deltaY * WHEEL_ZOOM), panel.minDistance, panel.maxDistance)
703
+ },
704
+ })
705
+ let input = cameras.map(panelInput)
706
+
707
+ let last = 0
708
+ onFrame(tick => {
709
+ let now = tick / 1000
710
+ // Clamp both ends: the runtime's tick counter resets across a hot reload,
711
+ // which makes exactly one frame's delta hugely negative.
712
+ let dt = clamp(now - last, 0, 0.1)
713
+ last = now
714
+ // Every panel in turn: glide its pending wheel zoom, then hold its eye
715
+ // above the floor.
716
+ for (let panel of cameras) {
717
+ // The glide is an exponential ease, framerate independent because the
718
+ // step is 1 - e^(-rate*dt) rather than a fixed fraction. It writes the
719
+ // pose, which the camera's own update() then pushes once.
720
+ if (panel.zoom !== null) {
721
+ let distance = panel.cam.pose().distance
722
+ let next = distance + (panel.zoom - distance) * (1 - Math.exp(-ZOOM_EASE * dt))
723
+ if (Math.abs(panel.zoom - next) < ZOOM_EPSILON) {
724
+ next = panel.zoom
725
+ panel.zoom = null
726
+ }
727
+ panel.cam.set({ distance: next })
728
+ }
729
+ // Keep the eye above the floor: the lowest elevation that still clears
730
+ // EYE_MIN_Y at this camera's distance. It tightens as the zoom pulls
731
+ // out, so a camera slides up along the limit instead of sinking through
732
+ // the ground - and the ground is one back-face-culled quad, so under it
733
+ // the floor and its shadows simply vanish.
734
+ holdAboveFloor(panel.cam)
735
+ }
736
+ // Turn the light rig, on the same pause as the orbit so a parked scene
737
+ // is a still frame end to end and snapshots of one pose repeat. This
738
+ // single write moves three lights, their direction slots and the shadow
739
+ // camera; the map is then re-rendered for the frame, which is what
740
+ // pausing buys back.
741
+ if (orbit.orbiting()) {
742
+ rigAngle = (rigAngle + (dt * Math.PI * 2) / RIG_PERIOD) % (Math.PI * 2)
743
+ setTransform(rig, { rotation: [0, rigAngle, 0] })
744
+ }
745
+ // Each panel's target gets its own uViewProj/uCamPos write, and only
746
+ // when that pose actually changed - update() reports it and skips.
747
+ for (let panel of cameras) panel.cam.update(dt)
748
+ })
749
+
750
+ return (
751
+ <window
752
+ onKeyDown={e => {
753
+ // Keyboard-only, and global: the large view leads, the small ones
754
+ // take whatever it just became.
755
+ if (e.code === "Space" || e.key === " ") setAllOrbiting(!orbit.orbiting())
756
+ }}
757
+ >
758
+ <d-texture src={scene.texture} {...panels().main} {...input[0]} />
759
+ <d-texture src={topView.texture} {...panels().top} {...input[1]} />
760
+ <d-texture src={bottomView.texture} {...panels().bottom} {...input[2]} />
761
+ <view
762
+ width={panels().main.w}
763
+ height={panels().main.h}
764
+ // Decoration only: it covers the whole hero panel, so without this a
765
+ // drag anywhere over the title or the hint would hit the overlay and
766
+ // never reach the texture leaf behind it.
767
+ pointerEvents="none"
768
+ justifyContent="space-between"
769
+ padding={28}
770
+ paddingTop={safeArea().top + 28}
771
+ // The hero panel only reaches the window's bottom edge in landscape;
772
+ // in portrait the two small panels sit under it and the inset there
773
+ // belongs to them.
774
+ paddingBottom={panels().main.h === windowSize().height ? safeArea().bottom + 28 : 28}
775
+ >
776
+ <view gap={6}>
777
+ <text color="#eef4ff" fontSize={30} fontWeight={700}>
778
+ The Third Dimension
779
+ </text>
780
+ <text color="#a9bcd6" fontSize={16} fontWeight={600}>
781
+ {`(${KNOT_P},${KNOT_Q}) torus knot - ${triangles.toLocaleString()} triangles - ${
782
+ casters() > 0 ? `${casters()} shadow map${casters() === 1 ? "" : "s"} - ` : ""
783
+ }3 views of one scene`}
784
+ </text>
785
+ </view>
786
+ <text color="#a9bcd6" fontSize={16} fontWeight={600}>
787
+ {orbit.orbiting()
788
+ ? capabilities.touch
789
+ ? "drag a panel to orbit it - pinch to zoom - tap one to pause it"
790
+ : "drag a panel to orbit it - wheel to zoom - click one to pause it, space for all"
791
+ : capabilities.touch
792
+ ? "paused - tap a panel to resume it"
793
+ : "paused - click a panel to resume it, space for all"}
794
+ </text>
795
+ </view>
796
+ </window>
797
+ )
798
+ }
799
+
800
+ // Debug commands for driving the app over MCP (list_debug / call_debug): park
801
+ // the camera at an exact pose, then snapshot. The pose reaches the scene on
802
+ // the next frame (onFrame updates the orbit every frame), and get_snapshot's
803
+ // own requested frame runs that callback first, so a park-then-snapshot
804
+ // sequence sees the parked pose. flush() applies the orbiting signal write
805
+ // before the command returns its result.
806
+ registerDebug("camera", (args?: Record<string, unknown>) => {
807
+ // Which panel to park: "top" or "bottom" for the right-hand pair, the
808
+ // large one otherwise. A pose is always one panel's; `orbiting` follows the
809
+ // app's own split - named panel, that panel alone (a click), no panel, all
810
+ // three (space). A parked distance also drops that panel's pending wheel
811
+ // glide, which would otherwise slide it straight back off.
812
+ let panel = args?.panel === "top" || args?.panel === "bottom" ? args.panel : "main"
813
+ let cam = panel === "top" ? topOrbit : panel === "bottom" ? bottomOrbit : orbit
814
+ if (typeof args?.distance === "number") {
815
+ let entry = cameras.find(p => p.cam === cam)
816
+ if (entry !== undefined) entry.zoom = null
817
+ }
818
+ if (typeof args?.orbiting === "boolean") {
819
+ if (args.panel === undefined) setAllOrbiting(args.orbiting)
820
+ else cam.set({ orbiting: args.orbiting })
821
+ }
822
+ cam.set({
823
+ azimuth: typeof args?.azimuth === "number" ? args.azimuth : undefined,
824
+ elevation: typeof args?.elevation === "number" ? args.elevation : undefined,
825
+ distance: typeof args?.distance === "number" ? args.distance : undefined,
826
+ })
827
+ flush()
828
+ return { panel, ...cam.pose(), orbiting: cam.orbiting() }
829
+ })
830
+
831
+ registerDebug("vertexLayout", () => ({ floatsPerVertex: STANDARD_FLOATS }))
832
+
833
+ // Where the light rig has turned to, radians. Its spin is paused with the
834
+ // orbit, so parking both is what makes a snapshot repeat.
835
+ registerDebug("rig", (args?: Record<string, unknown>) => {
836
+ if (typeof args?.angle === "number") {
837
+ rigAngle = args.angle % (Math.PI * 2)
838
+ setTransform(rig, { rotation: [0, rigAngle, 0] })
839
+ }
840
+ flush()
841
+ return { angle: rigAngle, period: RIG_PERIOD }
842
+ })
843
+
844
+ // Switch one light's shadow off or on: `{ light: 1, cast: false }`. Every
845
+ // light casts by default now that a scene takes MAX_SHADOWS = MAX_LIGHTS of
846
+ // them, so this is how the effect comes apart - leave one caster and its
847
+ // shadow is a plain complement, leave none and the floor is flat again.
848
+ registerDebug("shadow", (args?: Record<string, unknown>) => {
849
+ if (typeof args?.light === "number" && typeof args?.cast === "boolean") {
850
+ let i = clamp(Math.round(args.light), 0, lights.length - 1)
851
+ setLight(lights[i]!, { castShadow: args.cast })
852
+ setCasters(lights.filter(l => l.castShadow).length)
853
+ }
854
+ flush()
855
+ return { casting: lights.map(l => l.castShadow), tints: LIGHT_TINTS }
856
+ })
857
+
858
+ // Pixels per logical unit the scene renders at (0 follows the display): the
859
+ // quality/throughput knob, and the A/B for "is this pass fill-bound?".
860
+ registerDebug("renderScale", (args?: Record<string, unknown>) => {
861
+ if (typeof args?.scale === "number") setRenderScale(clamp(args.scale, 0, 4))
862
+ flush()
863
+ return { scale: renderScale() }
864
+ })
865
+
866
+ render(() => <App />)