@solidrt/3d 0.0.55 → 0.0.56
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 +1083 -183
- package/README.md +15 -7
- package/examples/README.md +79 -5
- package/examples/aim.tsx +2 -3
- package/examples/cascades.tsx +1 -4
- package/examples/collision.tsx +250 -0
- package/examples/first-person.tsx +119 -0
- package/examples/fleet.tsx +194 -0
- package/examples/fog.tsx +2 -3
- package/examples/instanced.tsx +72 -32
- package/examples/lamps.tsx +92 -0
- package/examples/lit.tsx +3 -5
- package/examples/materials.tsx +150 -0
- package/examples/model-load.tsx +95 -0
- package/examples/model.glb +0 -0
- package/examples/model.tsx +32 -6
- package/examples/pick.tsx +2 -4
- package/examples/probe.tsx +101 -0
- package/examples/scene-background.tsx +2 -4
- package/examples/scene-basic.tsx +2 -4
- package/examples/scene-views.tsx +32 -3
- package/examples/shadows.tsx +2 -4
- package/examples/sky-lit.tsx +86 -0
- package/examples/skybox.tsx +128 -0
- package/examples/sprites.tsx +4 -6
- package/examples/standard.tsx +132 -0
- package/examples/sweep-paths.tsx +2 -4
- package/package.json +3 -3
- package/src/camera.ts +148 -0
- package/src/clip.ts +96 -0
- package/src/collision.ts +199 -0
- package/src/color.ts +32 -0
- package/src/components.tsx +763 -82
- package/src/environment-bake.ts +492 -0
- package/src/environment.ts +325 -0
- package/src/first-person.ts +306 -0
- package/src/geometry.ts +6 -2
- package/src/glsl.ts +1343 -82
- package/src/gltf.ts +432 -112
- package/src/index.ts +57 -9
- package/src/light.ts +383 -0
- package/src/material.ts +667 -232
- package/src/math.ts +110 -0
- package/src/mesh.ts +728 -0
- package/src/mixer.ts +476 -0
- package/src/model-file.ts +80 -15
- package/src/model.ts +204 -35
- package/src/node.ts +506 -0
- package/src/orbit.ts +97 -48
- package/src/scene-pointer.ts +166 -0
- package/src/scene-shadows.ts +471 -0
- package/src/scene.ts +1242 -1557
- package/tools/environment.ts +69 -0
- package/tools/model.ts +4 -3
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// Environment helpers: the baked HDR environment loader (the .srte chain
|
|
2
|
+
// `srt tool 3d/environment` writes), the equirectangular-to-cube
|
|
3
|
+
// conversion a fetched LDR panorama needs at runtime, the GPU prefilter
|
|
4
|
+
// that turns a rendered cube (a reflection probe's, a baked sky's) into
|
|
5
|
+
// the roughness chain, and the placeholder cube a scene binds as uEnv
|
|
6
|
+
// while no environment is set. The face passes here render straight into
|
|
7
|
+
// cube draw targets: no readback, no re-upload.
|
|
8
|
+
|
|
9
|
+
import { file } from "flux:fs"
|
|
10
|
+
import {
|
|
11
|
+
addDraw,
|
|
12
|
+
compileShader,
|
|
13
|
+
createCubeDrawTarget,
|
|
14
|
+
createCubeTexture,
|
|
15
|
+
createRenderPipeline,
|
|
16
|
+
destroyProgram,
|
|
17
|
+
destroyRenderPipeline,
|
|
18
|
+
destroyShader,
|
|
19
|
+
destroyTexture,
|
|
20
|
+
glsl,
|
|
21
|
+
limits,
|
|
22
|
+
linkProgram,
|
|
23
|
+
removeDraw,
|
|
24
|
+
renderTarget,
|
|
25
|
+
setDrawParams,
|
|
26
|
+
} from "@solidrt/core/gpu"
|
|
27
|
+
import type { CreateOptions, DrawId, ProgramId, RenderPipelineId, SamplerOptions, TextureFormatOptions, TextureId } from "@solidrt/core/gpu"
|
|
28
|
+
import { decodeEnvironment, levelRoughness, mipLevels } from "./environment-bake.ts"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read a baked environment (`srt tool 3d/environment sky.hdr -o
|
|
32
|
+
* assets/sky.srte`) and upload it: the HDR cube with its GGX-prefiltered
|
|
33
|
+
* mip chain, as an explicit "rgba16f" chain - no generated mipmaps, so it
|
|
34
|
+
* works on every device. The id is what `environment={{ cube }}` and
|
|
35
|
+
* `background={{ cube }}` take (Three's `scene.environment` from an
|
|
36
|
+
* RGBELoader + PMREMGenerator, Unity's convolved reflection probe, Godot's
|
|
37
|
+
* radiance map, all done at build time here). Created after an await, so
|
|
38
|
+
* it is NOT auto-freed: an environment normally lives as long as the app;
|
|
39
|
+
* destroyTexture it otherwise. The same async shape as loadModel (see
|
|
40
|
+
* examples/model-load.tsx for the <Loading> pattern).
|
|
41
|
+
*/
|
|
42
|
+
export async function loadEnvironment(path: string, opts?: CreateOptions): Promise<TextureId> {
|
|
43
|
+
let { size, levels } = decodeEnvironment(await file(path).bytes())
|
|
44
|
+
return createCubeTexture(levels, size, { ...opts, format: "rgba16f", mipmap: true, label: opts?.label ?? "environment" })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The GL cube-map table as GLSL (cubeDirection in environment-bake.ts):
|
|
48
|
+
// the world direction whose lookup lands on `face` at (s, t) in 0..1, t =
|
|
49
|
+
// 0 the first row, unnormalized. Every pass that writes a face texel
|
|
50
|
+
// (equirectToCube's, the prefilter's) derives its direction from it, so
|
|
51
|
+
// the face holds what a lookup of that direction returns.
|
|
52
|
+
const CUBE_FACE_DIRECTION = glsl`
|
|
53
|
+
vec3 cubeFaceDirection(int face, vec2 st) {
|
|
54
|
+
float a = 2.0 * st.x - 1.0;
|
|
55
|
+
float b = 2.0 * st.y - 1.0;
|
|
56
|
+
if (face == 0) return vec3(1.0, -b, -a);
|
|
57
|
+
if (face == 1) return vec3(-1.0, -b, a);
|
|
58
|
+
if (face == 2) return vec3(a, 1.0, b);
|
|
59
|
+
if (face == 3) return vec3(a, -1.0, -b);
|
|
60
|
+
if (face == 4) return vec3(a, -b, 1.0);
|
|
61
|
+
return vec3(-a, -b, -1.0);
|
|
62
|
+
}
|
|
63
|
+
`
|
|
64
|
+
|
|
65
|
+
// The vertex stage of every face pass: the attributeless covering
|
|
66
|
+
// triangle emitting vUV with t = 0 at the face's first row, so the
|
|
67
|
+
// fragment's texel is (s, t) of the GL cube table - the same vUV the
|
|
68
|
+
// background and shader-target passes carry.
|
|
69
|
+
const FACE_VERTEX = glsl`
|
|
70
|
+
out vec2 vUV;
|
|
71
|
+
void main() {
|
|
72
|
+
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
|
|
73
|
+
vUV = p;
|
|
74
|
+
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
|
75
|
+
}
|
|
76
|
+
`
|
|
77
|
+
|
|
78
|
+
// A face pass: FACE_VERTEX over `fragment`, one pipeline owned by the
|
|
79
|
+
// caller (destroyed through `dispose` once its faces are rendered).
|
|
80
|
+
function facePipeline(fragment: string, label: string): { pipeline: RenderPipelineId; program: ProgramId; dispose(): void } {
|
|
81
|
+
let vs = compileShader("vertex", FACE_VERTEX, { header: true })
|
|
82
|
+
let fs = compileShader("fragment", fragment, { header: true })
|
|
83
|
+
let program = linkProgram(vs, fs, { label })
|
|
84
|
+
destroyShader(vs)
|
|
85
|
+
destroyShader(fs)
|
|
86
|
+
let pipeline = createRenderPipeline(program, { label })
|
|
87
|
+
return {
|
|
88
|
+
pipeline,
|
|
89
|
+
program,
|
|
90
|
+
dispose() {
|
|
91
|
+
destroyRenderPipeline(pipeline)
|
|
92
|
+
destroyProgram(program)
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Render the six faces of a cube draw target whose one entry reads uFace:
|
|
98
|
+
// the first five into level 0 explicitly (an explicit level regenerates
|
|
99
|
+
// nothing) and the last plainly, so a mipmapped cube regenerates its
|
|
100
|
+
// chain once.
|
|
101
|
+
function renderFaces(cube: TextureId, entry: DrawId): void {
|
|
102
|
+
for (let face = 0; face < 6; face++) {
|
|
103
|
+
setDrawParams(cube, entry, { uFace: face })
|
|
104
|
+
if (face < 5) renderTarget(cube, face, 0)
|
|
105
|
+
else renderTarget(cube, face)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// One face of the cube: the texel's sampling direction from the GL
|
|
110
|
+
// cube-map table. The panorama's center column faces -Z (the
|
|
111
|
+
// camera's default forward; Godot's PanoramaSkyMaterial and Unity's
|
|
112
|
+
// Skybox/Panoramic agree; Three centers +X, a quarter turn away), its top
|
|
113
|
+
// row +Y. At the seam column the uv derivative jumps by a full turn;
|
|
114
|
+
// zeroing that component keeps a mipmapped source from drawing a line.
|
|
115
|
+
// An sRGB source samples decoded and the face is written linear; a cube
|
|
116
|
+
// created at the same format encodes it on write and decodes on sample,
|
|
117
|
+
// so the bytes round-trip.
|
|
118
|
+
const EQUIRECT_FACE = glsl`
|
|
119
|
+
in vec2 vUV;
|
|
120
|
+
uniform sampler2D uMap;
|
|
121
|
+
uniform float uFace;
|
|
122
|
+
${CUBE_FACE_DIRECTION}
|
|
123
|
+
const float PI = 3.14159265358979;
|
|
124
|
+
// A uv derivative larger than this is the seam wrap, not a real step.
|
|
125
|
+
const float SEAM_JUMP = 0.5;
|
|
126
|
+
void main() {
|
|
127
|
+
vec3 d = normalize(cubeFaceDirection(int(uFace + 0.5), vUV));
|
|
128
|
+
vec2 uv = vec2(atan(d.x, -d.z) / (2.0 * PI) + 0.5, acos(clamp(d.y, -1.0, 1.0)) / PI);
|
|
129
|
+
vec2 dx = dFdx(uv);
|
|
130
|
+
vec2 dy = dFdy(uv);
|
|
131
|
+
if (abs(dx.x) > SEAM_JUMP) dx.x = 0.0;
|
|
132
|
+
if (abs(dy.x) > SEAM_JUMP) dy.x = 0.0;
|
|
133
|
+
fragColor = textureGrad(uMap, uv, dx, dy);
|
|
134
|
+
}
|
|
135
|
+
`
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Convert an equirectangular panorama (an uploaded 2D texture: createImage,
|
|
139
|
+
* createTexture) into a cube map of `size` x `size` faces, returned as a
|
|
140
|
+
* cube TextureId ready for a skybox or the environment - Three's
|
|
141
|
+
* `WebGLCubeRenderTarget.fromEquirectangularTexture`, Unity's cube import
|
|
142
|
+
* of a lat-long image. The six faces render on the GPU straight into a
|
|
143
|
+
* cube draw target (no readback, no upload), synchronously. `opts` are
|
|
144
|
+
* createCubeTexture's (`mipmap: true` for an environment shininess can
|
|
145
|
+
* blur; `label`; `autoFree: false` to own it), and `format` names the
|
|
146
|
+
* PANORAMA's format so the cube decodes like it: "rgba8-srgb" for a
|
|
147
|
+
* photographed sky uploaded as such, "rgba8" (default) for data,
|
|
148
|
+
* "rgba16f" for an HDR panorama (the cube keeps the range; renderable on
|
|
149
|
+
* every device with half-float rendering, else it throws - the prefiltered
|
|
150
|
+
* form of that panorama is `srt tool 3d/environment` plus
|
|
151
|
+
* loadEnvironment). The panorama's center column faces -Z and its top
|
|
152
|
+
* row is +Y. Leave its wrap at the default clamp: `repeat` would also
|
|
153
|
+
* wrap vertically and bleed the poles across the top and bottom rows,
|
|
154
|
+
* while the clamped seam column costs at most a texel-wide blend at +Z.
|
|
155
|
+
* Three centers its panoramas on +X: a rotation tuned there differs by a
|
|
156
|
+
* quarter turn here.
|
|
157
|
+
*/
|
|
158
|
+
export function equirectToCube(map: TextureId, size: number, opts?: CreateOptions & SamplerOptions & TextureFormatOptions): TextureId {
|
|
159
|
+
if (!Number.isInteger(size) || size < 1) throw new Error("equirectToCube: size must be a positive integer, got " + size)
|
|
160
|
+
let format = opts?.format ?? "rgba8"
|
|
161
|
+
if (format !== "rgba8" && format !== "rgba8-srgb" && format !== "rgba16f") {
|
|
162
|
+
throw new Error('equirectToCube: format must be "rgba8", "rgba8-srgb" or "rgba16f" (the panorama\'s), got ' + format)
|
|
163
|
+
}
|
|
164
|
+
let label = opts?.label ?? "equirect"
|
|
165
|
+
let cube = createCubeDrawTarget(size, null, { ...opts, format, label })
|
|
166
|
+
let pass = facePipeline(EQUIRECT_FACE, label + "-face")
|
|
167
|
+
let entry = addDraw(cube, pass.pipeline, { uFace: 0 }, { vertexCount: 3, textures: { uMap: map } })
|
|
168
|
+
renderFaces(cube, entry)
|
|
169
|
+
// The faces are pixels now; the pass and its binding of the panorama go.
|
|
170
|
+
removeDraw(cube, entry)
|
|
171
|
+
pass.dispose()
|
|
172
|
+
return cube
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// One face of one level of the prefiltered chain: prefilterCube's GGX
|
|
176
|
+
// convolution (environment-bake.ts) as a fragment. The texel's direction
|
|
177
|
+
// is the normal, view and reflection direction at once (N = V = R, every
|
|
178
|
+
// engine's bake), the GGX lobe at uRoughness is importance sampled with a
|
|
179
|
+
// Hammersley set, each sample reading the sharp source cube at the mip
|
|
180
|
+
// level whose texel covers the sample's solid angle (the source carries a
|
|
181
|
+
// generated chain), weighted by the reflected direction's cosine. A
|
|
182
|
+
// roughness of 0 is the base level: a copy.
|
|
183
|
+
const PREFILTER_FACE = glsl`
|
|
184
|
+
in vec2 vUV;
|
|
185
|
+
uniform samplerCube uSource;
|
|
186
|
+
uniform float uFace;
|
|
187
|
+
uniform float uRoughness;
|
|
188
|
+
${CUBE_FACE_DIRECTION}
|
|
189
|
+
const float PI = 3.14159265358979;
|
|
190
|
+
// GGX samples per texel: the CPU bake takes 512; reading each sample at
|
|
191
|
+
// the lod of its solid angle lets a fraction of that converge on the GPU.
|
|
192
|
+
const int PREFILTER_SAMPLES = 64;
|
|
193
|
+
// Bias on the source lod a sample reads (Karis: +1 smooths the estimate
|
|
194
|
+
// without visible extra blur).
|
|
195
|
+
const float SOURCE_LOD_BIAS = 1.0;
|
|
196
|
+
// A sample's normal-lobe cosine below this contributes nothing (the lobe
|
|
197
|
+
// mirrored below the horizon).
|
|
198
|
+
const float MIN_NOL = 1e-6;
|
|
199
|
+
// |n.z| above which the tangent frame's helper axis switches to +X.
|
|
200
|
+
const float POLE = 0.999;
|
|
201
|
+
// Van der Corput radical inverse: the second Hammersley coordinate.
|
|
202
|
+
float radicalInverse(uint bits) {
|
|
203
|
+
bits = (bits << 16u) | (bits >> 16u);
|
|
204
|
+
bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);
|
|
205
|
+
bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);
|
|
206
|
+
bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);
|
|
207
|
+
bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);
|
|
208
|
+
return float(bits) / 4294967296.0;
|
|
209
|
+
}
|
|
210
|
+
void main() {
|
|
211
|
+
vec3 n = normalize(cubeFaceDirection(int(uFace + 0.5), vUV));
|
|
212
|
+
if (uRoughness <= 0.0) {
|
|
213
|
+
fragColor = vec4(textureLod(uSource, n, 0.0).rgb, 1.0);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
float alpha = uRoughness * uRoughness;
|
|
217
|
+
float a2 = alpha * alpha;
|
|
218
|
+
float edge = float(textureSize(uSource, 0).x);
|
|
219
|
+
// The solid angle of one base-level source texel.
|
|
220
|
+
float texelAngle = 4.0 * PI / (6.0 * edge * edge);
|
|
221
|
+
vec3 up = abs(n.z) > POLE ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 0.0, 1.0);
|
|
222
|
+
vec3 t = normalize(cross(up, n));
|
|
223
|
+
vec3 b = cross(n, t);
|
|
224
|
+
vec3 acc = vec3(0.0);
|
|
225
|
+
float weight = 0.0;
|
|
226
|
+
for (int i = 0; i < PREFILTER_SAMPLES; i++) {
|
|
227
|
+
float u1 = (float(i) + 0.5) / float(PREFILTER_SAMPLES);
|
|
228
|
+
float u2 = radicalInverse(uint(i));
|
|
229
|
+
float phi = 2.0 * PI * u1;
|
|
230
|
+
float cosT = sqrt((1.0 - u2) / (1.0 + (a2 - 1.0) * u2));
|
|
231
|
+
float sinT = sqrt(max(0.0, 1.0 - cosT * cosT));
|
|
232
|
+
// N = V, so the reflected sample's cosine is 2 cos^2 - 1.
|
|
233
|
+
float nol = 2.0 * cosT * cosT - 1.0;
|
|
234
|
+
if (nol <= MIN_NOL) continue;
|
|
235
|
+
vec3 h = t * (sinT * cos(phi)) + b * (sinT * sin(phi)) + n * cosT;
|
|
236
|
+
vec3 l = 2.0 * cosT * h - n;
|
|
237
|
+
float d = cosT * cosT * (a2 - 1.0) + 1.0;
|
|
238
|
+
float pdf = a2 / (PI * d * d) / 4.0;
|
|
239
|
+
float sampleAngle = 1.0 / (float(PREFILTER_SAMPLES) * pdf);
|
|
240
|
+
float lod = max(0.0, 0.5 * log2(sampleAngle / texelAngle) + SOURCE_LOD_BIAS);
|
|
241
|
+
acc += textureLod(uSource, l, lod).rgb * nol;
|
|
242
|
+
weight += nol;
|
|
243
|
+
}
|
|
244
|
+
fragColor = vec4(acc / weight, 1.0);
|
|
245
|
+
}
|
|
246
|
+
`
|
|
247
|
+
|
|
248
|
+
/** A roughness chain rendered on the GPU from a sharp cube (internal: the
|
|
249
|
+
* reflection probe's and bakeBackground's second half). */
|
|
250
|
+
export type Prefilter = {
|
|
251
|
+
/** The chain: a mipmapped cube draw target of the source's size, what
|
|
252
|
+
* the environment samples. */
|
|
253
|
+
cube: TextureId
|
|
254
|
+
/** Fill every level of every face from the source's current pixels:
|
|
255
|
+
* level 0 a copy, each level below convolved at its levelRoughness. */
|
|
256
|
+
run(): void
|
|
257
|
+
/** Drop the pass (its pipeline, program and binding of the source) and
|
|
258
|
+
* hand over the chain, which keeps its pixels: a one-shot bake's end.
|
|
259
|
+
* The source is free to go afterwards. */
|
|
260
|
+
finish(): TextureId
|
|
261
|
+
/** finish() plus the chain itself. */
|
|
262
|
+
dispose(): void
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The format of a rendered radiance cube (a probe's faces, its chain, a
|
|
266
|
+
* baked sky): half float where the device renders it, so the range of a
|
|
267
|
+
* sun or an emissive survives into reflections as in every engine's HDR
|
|
268
|
+
* probe, else 8-bit and clamped - the picture degrades, the app runs. Not
|
|
269
|
+
* a knob: the renderer decides for all probes (Godot), HDR by default
|
|
270
|
+
* (Unity). */
|
|
271
|
+
export type ProbeFormat = "rgba8" | "rgba16f"
|
|
272
|
+
export function probeFormat(): ProbeFormat {
|
|
273
|
+
return limits.halfFloatRenderable ? "rgba16f" : "rgba8"
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The runtime counterpart of the bake tool's prefilterCube: a mipmapped
|
|
278
|
+
* cube draw target of `size` and `format` whose chain `run()` fills from
|
|
279
|
+
* `source`, a `mipmap: true` cube of the same size and format (a cube
|
|
280
|
+
* draw target rendered sharp), one pass per face per level - 48 small
|
|
281
|
+
* passes at 128. Three's PMREMGenerator, Unity's and Godot's probe
|
|
282
|
+
* convolution, on the same roughness-to-level rule as the .srte chain
|
|
283
|
+
* (levelRoughness), so `standard` reads both alike.
|
|
284
|
+
*/
|
|
285
|
+
export function createPrefilter(size: number, source: TextureId, format: ProbeFormat, label: string): Prefilter {
|
|
286
|
+
let pass: ReturnType<typeof facePipeline> | null = facePipeline(PREFILTER_FACE, label + "-prefilter")
|
|
287
|
+
let cube = createCubeDrawTarget(size, null, { mipmap: true, format, autoFree: false, label })
|
|
288
|
+
let entry = addDraw(cube, pass.pipeline, { uFace: 0, uRoughness: 0 }, { vertexCount: 3, textures: { uSource: source } })
|
|
289
|
+
let levels = mipLevels(size)
|
|
290
|
+
let finish = () => {
|
|
291
|
+
if (pass !== null) {
|
|
292
|
+
removeDraw(cube, entry)
|
|
293
|
+
pass.dispose()
|
|
294
|
+
pass = null
|
|
295
|
+
}
|
|
296
|
+
return cube
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
cube,
|
|
300
|
+
run() {
|
|
301
|
+
if (pass === null) return
|
|
302
|
+
for (let level = 0; level < levels; level++) {
|
|
303
|
+
// The base is a copy whatever the size (a chain of 4 or less has
|
|
304
|
+
// no ramp: levelRoughness is 1 everywhere), as the CPU bake's is.
|
|
305
|
+
let roughness = level === 0 ? 0 : levelRoughness(size, level)
|
|
306
|
+
for (let face = 0; face < 6; face++) {
|
|
307
|
+
setDrawParams(cube, entry, { uFace: face, uRoughness: roughness })
|
|
308
|
+
renderTarget(cube, face, level)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
finish,
|
|
313
|
+
dispose() {
|
|
314
|
+
destroyTexture(finish())
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The 1x1 black cube a scene binds as uEnv while it has no environment
|
|
320
|
+
* (uEnvOn 0 makes the term vanish; the binding only keeps the sampler
|
|
321
|
+
* complete). Owned by the scene, which destroys it with itself. */
|
|
322
|
+
export function createEnvironmentPlaceholder(label: string): TextureId {
|
|
323
|
+
let black = new Uint8Array([0, 0, 0, 255])
|
|
324
|
+
return createCubeTexture([black, black, black, black, black, black], 1, { autoFree: false, label })
|
|
325
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// A first-person camera for a scene: a position plus yaw/pitch, with mouse
|
|
2
|
+
// look under pointer lock, drag-to-look without it (the touch path), and
|
|
3
|
+
// walk (or fly) from keys and gamepad sticks - Unity's FirstPersonController
|
|
4
|
+
// shape, look and move in one control, rather than Three's split into
|
|
5
|
+
// PointerLockControls (look only) and a hand-written key loop.
|
|
6
|
+
//
|
|
7
|
+
// Look input has three sources, all summed into the same yaw/pitch:
|
|
8
|
+
// - pointer-move deltas (movementX/movementY) while the pointer is locked,
|
|
9
|
+
// the desktop mouse-look path; the control never engages the lock itself
|
|
10
|
+
// (click-to-lock and Escape-to-release are window-level decisions that
|
|
11
|
+
// stay app code, see lockPointer in @solidrt/core);
|
|
12
|
+
// - a one-finger drag through core's transform recognizer while NOT locked,
|
|
13
|
+
// so a touch viewport looks around with the arena arbitration the orbit
|
|
14
|
+
// camera has, and viewport-relative like it;
|
|
15
|
+
// - the right stick, read from core's gamepads() at update time.
|
|
16
|
+
// Move input: WASD/arrows (physical codes AND logical keys, so a layout
|
|
17
|
+
// or a synthetic event without a code both work), Q/E for down/up in fly
|
|
18
|
+
// mode, and the left stick. Walking flattens the forward vector onto the
|
|
19
|
+
// ground plane at fixed height; flying moves along the view direction.
|
|
20
|
+
//
|
|
21
|
+
// Pose is plain mutable state advanced by update(dt) from the app's own
|
|
22
|
+
// onFrame, as in orbit.ts: only `active` is reactive - whether anything is
|
|
23
|
+
// held or deflected, what a frame loop gates on; it derives from the held
|
|
24
|
+
// count and core's gamepads() so a stick moved while the loop is off wakes
|
|
25
|
+
// it - while the pose moves at frame rate and bypasses reactivity. Held
|
|
26
|
+
// keys cannot be polled (there is no key-state accessor in core), so the
|
|
27
|
+
// control tracks them from the down/up pair; an `onBlur` handler drops
|
|
28
|
+
// them all, because the up never arrives once focus has left.
|
|
29
|
+
//
|
|
30
|
+
// Collision is deliberately absent: a camera control cannot know the
|
|
31
|
+
// level. Clamp or reject positions through `clampPosition`.
|
|
32
|
+
//
|
|
33
|
+
// Options are read where they apply, not copied out: `fly`, `moveSpeed`,
|
|
34
|
+
// `lookSpeed`, the pitch clamps, `viewport` and `clampPosition` are
|
|
35
|
+
// re-read from the options object on every input or update, so a caller
|
|
36
|
+
// may change a field (or hand in an object of getters, which is what
|
|
37
|
+
// `<FirstPersonCamera>` does with its props) and the next move sees it -
|
|
38
|
+
// walk and fly are one control, not two mounts. Only the initial pose is
|
|
39
|
+
// copied at creation; later pose changes go through set().
|
|
40
|
+
|
|
41
|
+
import { createMemo, createSignal } from "@solidjs/signals"
|
|
42
|
+
import { createTransform, gamepads, pointerLocked } from "@solidrt/core"
|
|
43
|
+
import type { KeyEvent, PointerEvent } from "@solidrt/core"
|
|
44
|
+
import type { CameraUpdate } from "./camera.ts"
|
|
45
|
+
import type { Vec3 } from "./math.ts"
|
|
46
|
+
|
|
47
|
+
// Baseline sensitivities at lookSpeed 1. Mouse look is radians per moved
|
|
48
|
+
// logical pixel under lock (Three's PointerLockControls figure); with a
|
|
49
|
+
// `viewport` the drag is viewport-relative, one viewport height sweeping
|
|
50
|
+
// DRAG_TURNS turns, and without one it falls back to the mouse figure.
|
|
51
|
+
// The stick turns at LOOK_STICK radians/second at full deflection, with
|
|
52
|
+
// a dead zone below which a resting stick reads as zero.
|
|
53
|
+
const LOOK_MOUSE = 0.002
|
|
54
|
+
const DRAG_TURNS = 0.5
|
|
55
|
+
const LOOK_STICK = 2.5
|
|
56
|
+
const STICK_DEADZONE = 0.15
|
|
57
|
+
// Default walking speed, world units per second.
|
|
58
|
+
const MOVE_SPEED = 3
|
|
59
|
+
// Pitch clamps stop short of the poles so the look direction never
|
|
60
|
+
// degenerates against world up.
|
|
61
|
+
const PITCH_LIMIT = Math.PI / 2 - 0.01
|
|
62
|
+
|
|
63
|
+
/** What a first-person camera drives: a Scene, or one of its Views. */
|
|
64
|
+
export type FirstPersonTarget = { setCamera(update: CameraUpdate): void }
|
|
65
|
+
|
|
66
|
+
/** The pose fields (position, yaw, pitch) are initial values, copied at
|
|
67
|
+
* creation and changed through set() afterwards. Every other field is
|
|
68
|
+
* live: read from this object where it applies, so a change takes effect
|
|
69
|
+
* on the next input or update. */
|
|
70
|
+
export type FirstPersonCameraOptions = {
|
|
71
|
+
/** Initial eye position (default [0, 1.6, 0]: standing height at the
|
|
72
|
+
* origin). */
|
|
73
|
+
position?: Vec3
|
|
74
|
+
/** Initial look, radians. Yaw 0 faces -z (the camera default), positive
|
|
75
|
+
* turns left; pitch 0 is level, positive looks up. */
|
|
76
|
+
yaw?: number
|
|
77
|
+
pitch?: number
|
|
78
|
+
/** Pitch clamps, radians; the defaults stop just short of the poles. */
|
|
79
|
+
minPitch?: number
|
|
80
|
+
maxPitch?: number
|
|
81
|
+
/** Movement speed in world units per second (default 3). */
|
|
82
|
+
moveSpeed?: number
|
|
83
|
+
/** Multiplier over the built-in mouse, drag and stick look rates. */
|
|
84
|
+
lookSpeed?: number
|
|
85
|
+
/** Walk (default) keeps the height fixed and moves along the ground
|
|
86
|
+
* projection of the view; fly moves along the view itself and arms Q/E
|
|
87
|
+
* for down/up (bound either way, inert while walking). Toggling it on a
|
|
88
|
+
* running control keeps the pose and the held keys. */
|
|
89
|
+
fly?: boolean
|
|
90
|
+
/** The viewport a drag lives in: the input element's own laid-out
|
|
91
|
+
* height (the frame the recognizer's deltas arrive in); makes the drag
|
|
92
|
+
* viewport-relative. Null while unknown. */
|
|
93
|
+
viewport?: () => { height: number } | null
|
|
94
|
+
/** Constrain where a move may put the eye: called with the eye the
|
|
95
|
+
* move asks for and the eye it starts from (both fresh arrays), returns
|
|
96
|
+
* the position to use - a level's bounds, a floor height, a collision
|
|
97
|
+
* controller's `moveAndSlide` over the difference. Look does not
|
|
98
|
+
* consult it. */
|
|
99
|
+
clampPosition?: (next: Vec3, current: Vec3) => Vec3
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type FirstPersonPose = {
|
|
103
|
+
position?: Vec3
|
|
104
|
+
yaw?: number
|
|
105
|
+
pitch?: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type FirstPersonCamera = {
|
|
109
|
+
/** Eye position (a fresh array per call). */
|
|
110
|
+
eye(): Vec3
|
|
111
|
+
/** Unit look direction for the current pose (a fresh array per call). */
|
|
112
|
+
forward(): Vec3
|
|
113
|
+
/** Pose snapshot - the shape debug commands return and set() takes. */
|
|
114
|
+
pose(): { position: Vec3; yaw: number; pitch: number }
|
|
115
|
+
/** Merge a pose in (clamps apply); reaches the scene at the next
|
|
116
|
+
* update(). */
|
|
117
|
+
set(pose: FirstPersonPose): void
|
|
118
|
+
/** Whether a movement key is held or a stick deflected - what a frame
|
|
119
|
+
* loop should run on. Reactive (signal-backed). */
|
|
120
|
+
active(): boolean
|
|
121
|
+
/** Integrate held keys and sticks over dt seconds and push any pose
|
|
122
|
+
* change to the driven camera; returns whether the pose changed. */
|
|
123
|
+
update(dt: number): boolean
|
|
124
|
+
/** Spread onto the element that receives input. Keys route through the
|
|
125
|
+
* focused node, so that element must hold focus (or be the window). */
|
|
126
|
+
handlers: {
|
|
127
|
+
onPointerDown(e: PointerEvent): void
|
|
128
|
+
onPointerMove(e: PointerEvent): void
|
|
129
|
+
onPointerUp(e: PointerEvent): void
|
|
130
|
+
onKeyDown(e: KeyEvent): void
|
|
131
|
+
onKeyUp(e: KeyEvent): void
|
|
132
|
+
onBlur(): void
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type Move = "forward" | "back" | "left" | "right" | "up" | "down"
|
|
137
|
+
|
|
138
|
+
// Physical codes first (layout-independent), then the logical keys a
|
|
139
|
+
// synthetic event or an odd layout reports.
|
|
140
|
+
const KEY_MOVES: Record<string, Move> = {
|
|
141
|
+
KeyW: "forward", ArrowUp: "forward", w: "forward", W: "forward",
|
|
142
|
+
KeyS: "back", ArrowDown: "back", s: "back", S: "back",
|
|
143
|
+
KeyA: "left", ArrowLeft: "left", a: "left", A: "left",
|
|
144
|
+
KeyD: "right", ArrowRight: "right", d: "right", D: "right",
|
|
145
|
+
KeyE: "up", e: "up", E: "up",
|
|
146
|
+
KeyQ: "down", q: "down", Q: "down",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
|
|
150
|
+
let deadzone = (v: number) => (Math.abs(v) < STICK_DEADZONE ? 0 : v)
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Create a first-person camera driving `camera`'s position and target,
|
|
154
|
+
* where `camera` is a Scene or one of its Views (fov, near, and far stay
|
|
155
|
+
* yours via its setCamera). The initial pose applies immediately. In a
|
|
156
|
+
* component tree, prefer the `<FirstPersonCamera>` component: it wires
|
|
157
|
+
* scene, input, focus, viewport and the frame loop through the Scene
|
|
158
|
+
* context.
|
|
159
|
+
*/
|
|
160
|
+
export function createFirstPersonCamera(camera: FirstPersonTarget, options: FirstPersonCameraOptions = {}): FirstPersonCamera {
|
|
161
|
+
let position: Vec3 = options.position ? [options.position[0], options.position[1], options.position[2]] : [0, 1.6, 0]
|
|
162
|
+
let yaw = options.yaw ?? 0
|
|
163
|
+
let pitch = options.pitch ?? 0
|
|
164
|
+
// Everything below the pose is read from `options` where it applies.
|
|
165
|
+
let lookSpeed = () => options.lookSpeed ?? 1
|
|
166
|
+
let clampPitch = () => {
|
|
167
|
+
pitch = clampNum(pitch, options.minPitch ?? -PITCH_LIMIT, options.maxPitch ?? PITCH_LIMIT)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let held = new Set<Move>()
|
|
171
|
+
let [heldCount, setHeldCount] = createSignal(0)
|
|
172
|
+
let dirty = false
|
|
173
|
+
|
|
174
|
+
let forward = (): Vec3 => {
|
|
175
|
+
let cp = Math.cos(pitch)
|
|
176
|
+
return [-Math.sin(yaw) * cp, Math.sin(pitch), -Math.cos(yaw) * cp]
|
|
177
|
+
}
|
|
178
|
+
let apply = () => {
|
|
179
|
+
let f = forward()
|
|
180
|
+
camera.setCamera({ position: [position[0], position[1], position[2]], target: [position[0] + f[0], position[1] + f[1], position[2] + f[2]] })
|
|
181
|
+
}
|
|
182
|
+
let look = (dx: number, dy: number) => {
|
|
183
|
+
yaw -= dx
|
|
184
|
+
pitch -= dy
|
|
185
|
+
clampPitch()
|
|
186
|
+
dirty = true
|
|
187
|
+
}
|
|
188
|
+
let moveBy = (dx: number, dy: number, dz: number) => {
|
|
189
|
+
let next: Vec3 = [position[0] + dx, position[1] + dy, position[2] + dz]
|
|
190
|
+
position = options.clampPosition ? options.clampPosition(next, [position[0], position[1], position[2]]) : next
|
|
191
|
+
dirty = true
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The stick axes of every connected pad, summed: a game with one player
|
|
195
|
+
// sees its one pad, and a pad at rest contributes nothing.
|
|
196
|
+
let sticks = () => {
|
|
197
|
+
let s = { moveX: 0, moveY: 0, lookX: 0, lookY: 0 }
|
|
198
|
+
for (let pad of gamepads()) {
|
|
199
|
+
if (!pad) continue
|
|
200
|
+
s.moveX += deadzone(pad.axes.leftX ?? 0)
|
|
201
|
+
s.moveY += deadzone(pad.axes.leftY ?? 0)
|
|
202
|
+
s.lookX += deadzone(pad.axes.rightX ?? 0)
|
|
203
|
+
s.lookY += deadzone(pad.axes.rightY ?? 0)
|
|
204
|
+
}
|
|
205
|
+
return s
|
|
206
|
+
}
|
|
207
|
+
let active = createMemo(() => {
|
|
208
|
+
let s = sticks()
|
|
209
|
+
return heldCount() > 0 || s.moveX !== 0 || s.moveY !== 0 || s.lookX !== 0 || s.lookY !== 0
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// The unlocked look: a one-finger drag, arena-arbitrated. Two fingers
|
|
213
|
+
// have no first-person meaning and keep looking.
|
|
214
|
+
let transform = createTransform({
|
|
215
|
+
onTransformMove: t => {
|
|
216
|
+
let vp = options.viewport?.() ?? null
|
|
217
|
+
let rate = (vp !== null ? (DRAG_TURNS * 2 * Math.PI) / vp.height : LOOK_MOUSE) * lookSpeed()
|
|
218
|
+
look(t.dx * rate, t.dy * rate)
|
|
219
|
+
apply()
|
|
220
|
+
dirty = false
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
clampPitch()
|
|
225
|
+
apply()
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
eye: () => [position[0], position[1], position[2]],
|
|
229
|
+
forward,
|
|
230
|
+
pose: () => ({ position: [position[0], position[1], position[2]], yaw, pitch }),
|
|
231
|
+
active,
|
|
232
|
+
set(pose) {
|
|
233
|
+
if (pose.position) position = [pose.position[0], pose.position[1], pose.position[2]]
|
|
234
|
+
if (pose.yaw !== undefined) yaw = pose.yaw
|
|
235
|
+
if (pose.pitch !== undefined) pitch = pose.pitch
|
|
236
|
+
clampPitch()
|
|
237
|
+
dirty = true
|
|
238
|
+
},
|
|
239
|
+
update(dt) {
|
|
240
|
+
let s = sticks()
|
|
241
|
+
if (s.lookX !== 0 || s.lookY !== 0) {
|
|
242
|
+
let rate = LOOK_STICK * lookSpeed() * dt
|
|
243
|
+
look(s.lookX * rate, s.lookY * rate)
|
|
244
|
+
}
|
|
245
|
+
// Key axes: -1..1 per axis, the stick added on top (stick up is -y,
|
|
246
|
+
// the web convention, so forward is -leftY), then clamped so a key
|
|
247
|
+
// and a stick together do not exceed full speed.
|
|
248
|
+
let ahead = clampNum((held.has("forward") ? 1 : 0) - (held.has("back") ? 1 : 0) - s.moveY, -1, 1)
|
|
249
|
+
let side = clampNum((held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0) + s.moveX, -1, 1)
|
|
250
|
+
// One read per update, so a mode toggle lands between steps, never
|
|
251
|
+
// between the rise and the heading of the same step.
|
|
252
|
+
let fly = options.fly ?? false
|
|
253
|
+
let rise = fly ? (held.has("up") ? 1 : 0) - (held.has("down") ? 1 : 0) : 0
|
|
254
|
+
if (ahead !== 0 || side !== 0 || rise !== 0) {
|
|
255
|
+
let step = (options.moveSpeed ?? MOVE_SPEED) * dt
|
|
256
|
+
let f = forward()
|
|
257
|
+
// Walking projects the view onto the ground plane, so looking down
|
|
258
|
+
// does not slow the walk; right is always horizontal.
|
|
259
|
+
let fx = fly ? f[0] : -Math.sin(yaw)
|
|
260
|
+
let fy = fly ? f[1] : 0
|
|
261
|
+
let fz = fly ? f[2] : -Math.cos(yaw)
|
|
262
|
+
let rx = Math.cos(yaw)
|
|
263
|
+
let rz = -Math.sin(yaw)
|
|
264
|
+
moveBy((fx * ahead + rx * side) * step, (fy * ahead + rise) * step, (fz * ahead + rz * side) * step)
|
|
265
|
+
}
|
|
266
|
+
if (!dirty) return false
|
|
267
|
+
dirty = false
|
|
268
|
+
apply()
|
|
269
|
+
return true
|
|
270
|
+
},
|
|
271
|
+
handlers: {
|
|
272
|
+
onPointerDown(e) {
|
|
273
|
+
if (!pointerLocked()) transform.handlers.onPointerDown(e)
|
|
274
|
+
},
|
|
275
|
+
onPointerMove(e) {
|
|
276
|
+
if (pointerLocked()) {
|
|
277
|
+
let rate = LOOK_MOUSE * lookSpeed()
|
|
278
|
+
look(e.movementX * rate, e.movementY * rate)
|
|
279
|
+
apply()
|
|
280
|
+
dirty = false
|
|
281
|
+
} else {
|
|
282
|
+
transform.handlers.onPointerMove(e)
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
onPointerUp(e) {
|
|
286
|
+
transform.handlers.onPointerUp(e)
|
|
287
|
+
},
|
|
288
|
+
onKeyDown(e) {
|
|
289
|
+
let move = KEY_MOVES[e.code] ?? KEY_MOVES[e.key]
|
|
290
|
+
if (!move) return
|
|
291
|
+
held.add(move)
|
|
292
|
+
setHeldCount(held.size)
|
|
293
|
+
},
|
|
294
|
+
onKeyUp(e) {
|
|
295
|
+
let move = KEY_MOVES[e.code] ?? KEY_MOVES[e.key]
|
|
296
|
+
if (!move) return
|
|
297
|
+
held.delete(move)
|
|
298
|
+
setHeldCount(held.size)
|
|
299
|
+
},
|
|
300
|
+
onBlur() {
|
|
301
|
+
held.clear()
|
|
302
|
+
setHeldCount(0)
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
}
|
package/src/geometry.ts
CHANGED
|
@@ -28,7 +28,7 @@ import type { Quat, TransformUpdate, Vec2, Vec3 } from "./math.ts"
|
|
|
28
28
|
/** A vertex layout: the named presets, or an explicit attribute list that
|
|
29
29
|
* must begin with the standard prefix (aPos vec3, aNormal vec3, aUV vec2).
|
|
30
30
|
* Absent on a Geometry means "standard". */
|
|
31
|
-
export type VertexLayout = "standard" | "colored" | VertexAttribute[]
|
|
31
|
+
export type VertexLayout = "standard" | "colored" | "skinned" | VertexAttribute[]
|
|
32
32
|
|
|
33
33
|
const STANDARD_ATTRIBUTES: VertexAttribute[] = [
|
|
34
34
|
{ name: "aPos", format: "vec3" },
|
|
@@ -38,9 +38,12 @@ const STANDARD_ATTRIBUTES: VertexAttribute[] = [
|
|
|
38
38
|
|
|
39
39
|
/** The attribute lists behind the named layouts. Every layout shares the
|
|
40
40
|
* standard prefix, so one shader vocabulary serves all of them. */
|
|
41
|
-
export const VERTEX_LAYOUTS: Record<"standard" | "colored", VertexAttribute[]> = {
|
|
41
|
+
export const VERTEX_LAYOUTS: Record<"standard" | "colored" | "skinned", VertexAttribute[]> = {
|
|
42
42
|
standard: STANDARD_ATTRIBUTES,
|
|
43
43
|
colored: [...STANDARD_ATTRIBUTES, { name: "aColor", format: "vec4" }],
|
|
44
|
+
// The rigged-model layout: 4 joint indices (as floats - exact to 2^24)
|
|
45
|
+
// and their weights per vertex, what a skinned vertex stage reads.
|
|
46
|
+
skinned: [...STANDARD_ATTRIBUTES, { name: "aJoints", format: "vec4" }, { name: "aWeights", format: "vec4" }],
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/** Floats per vertex in the "standard" layout (the generators' own write
|
|
@@ -53,6 +56,7 @@ const FORMAT_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2:
|
|
|
53
56
|
export function layoutAttributes(layout?: VertexLayout): VertexAttribute[] {
|
|
54
57
|
if (layout === undefined || layout === "standard") return VERTEX_LAYOUTS.standard
|
|
55
58
|
if (layout === "colored") return VERTEX_LAYOUTS.colored
|
|
59
|
+
if (layout === "skinned") return VERTEX_LAYOUTS.skinned
|
|
56
60
|
return layout
|
|
57
61
|
}
|
|
58
62
|
|