@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.
- package/AGENTS.md +332 -91
- package/README.md +13 -9
- package/demos/README.md +15 -0
- package/demos/assets/icon.svg +23 -0
- package/demos/package.json +9 -0
- package/demos/src/the-third-dimension.tsx +866 -0
- package/demos/tsconfig.json +15 -0
- package/examples/README.md +29 -2
- package/examples/aim.tsx +5 -5
- package/examples/instanced.tsx +4 -4
- package/examples/lit.tsx +66 -0
- package/examples/model.glb +0 -0
- package/examples/model.tsx +51 -0
- package/examples/pick.tsx +5 -5
- package/examples/scene-background.tsx +3 -3
- package/examples/scene-basic.tsx +3 -3
- package/examples/scene-post-effect.tsx +3 -3
- package/examples/scene-views.tsx +95 -0
- package/examples/shadows.tsx +86 -0
- package/examples/sprites.tsx +95 -0
- package/examples/sweep-paths.tsx +11 -27
- package/package.json +4 -3
- package/src/components.tsx +110 -14
- package/src/geometry-gpu.ts +13 -2
- package/src/geometry.ts +331 -127
- package/src/glsl.ts +83 -3
- package/src/gltf.ts +437 -0
- package/src/index.ts +17 -12
- package/src/material.ts +352 -47
- package/src/math.ts +69 -0
- package/src/model-file.ts +122 -0
- package/src/model.ts +105 -0
- package/src/orbit.ts +13 -9
- package/src/order.ts +12 -5
- package/src/profile.ts +4 -8
- package/src/scene.ts +1075 -282
- package/src/sweep.ts +21 -36
- package/src/bvh.ts +0 -258
|
@@ -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 />)
|