@solidrt/3d 0.0.55 → 0.0.57
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 +1206 -224
- package/README.md +16 -7
- package/examples/README.md +88 -9
- package/examples/aim.tsx +2 -3
- package/examples/cascades.tsx +1 -4
- package/examples/collision.tsx +263 -0
- package/examples/first-person.tsx +129 -0
- package/examples/fleet.tsx +200 -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 +35 -9
- package/examples/pick.tsx +145 -37
- package/examples/probe.tsx +107 -0
- package/examples/scene-background.tsx +2 -4
- package/examples/scene-basic.tsx +2 -4
- package/examples/scene-views.tsx +68 -48
- package/examples/shadows.tsx +2 -4
- package/examples/sky-lit.tsx +92 -0
- package/examples/skybox.tsx +134 -0
- package/examples/sprites.tsx +4 -6
- package/examples/standard.tsx +138 -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 +20 -0
- package/src/color.ts +32 -0
- package/src/components/context.tsx +35 -0
- package/src/components/directional-light.tsx +48 -0
- package/src/components/first-person-camera.tsx +72 -0
- package/src/components/group.tsx +18 -0
- package/src/components/hemisphere-light.tsx +24 -0
- package/src/components/index.ts +46 -0
- package/src/components/instanced-mesh.tsx +105 -0
- package/src/components/mesh.tsx +92 -0
- package/src/components/node-props.ts +90 -0
- package/src/components/orbit-camera.tsx +75 -0
- package/src/components/perspective-camera.tsx +32 -0
- package/src/components/point-light.tsx +52 -0
- package/src/components/record-mesh.tsx +68 -0
- package/src/components/scene.tsx +281 -0
- package/src/components/spot-light.tsx +65 -0
- package/src/components/sprite.tsx +38 -0
- package/src/components/view3d.tsx +152 -0
- package/src/environment-bake.ts +492 -0
- package/src/environment.ts +325 -0
- package/src/first-person.ts +258 -0
- package/src/geometry.ts +6 -2
- package/src/glsl.ts +1343 -82
- package/src/gltf.ts +432 -112
- package/src/index.ts +61 -9
- package/src/input.ts +70 -0
- 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 +579 -0
- package/src/orbit.ts +292 -212
- package/src/scene-pointer.ts +294 -0
- package/src/scene-shadows.ts +471 -0
- package/src/scene.ts +1435 -1576
- package/tools/environment.ts +69 -0
- package/tools/model.ts +4 -3
- package/src/components.tsx +0 -420
|
@@ -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,258 @@
|
|
|
1
|
+
// A first-person camera for a scene: a position plus yaw/pitch, look and
|
|
2
|
+
// move in one control - Unity's FirstPersonController shape, rather than
|
|
3
|
+
// Three's split into PointerLockControls (look only) and a hand-written
|
|
4
|
+
// key loop. The control is pure (ARCHITECTURE.md: controls through an
|
|
5
|
+
// abstraction, never direct event handling): it consumes three axes,
|
|
6
|
+
// `look` (vec2), `move` (vec2) and `rise` (axis), in device-free units,
|
|
7
|
+
// and exposes pose verbs for scripted moves. Mouse look under pointer
|
|
8
|
+
// lock, drag-to-look on touch, WASD, sticks: all of that is the app's
|
|
9
|
+
// wiring through an input map, and `firstPersonBindings` in input.ts is
|
|
10
|
+
// the standard set.
|
|
11
|
+
//
|
|
12
|
+
// `look` deltas are element heights of travel (a drag across the element
|
|
13
|
+
// sweeps DRAG_TURNS turns) or the pointer feed's mouse-motion
|
|
14
|
+
// equivalent; a rate (a stick) turns at LOOK_RATE turns per second at
|
|
15
|
+
// full deflection. `move` is [right, forward] in the screen convention
|
|
16
|
+
// (forward is -y, as a stick pushed up reads): a rate walks at
|
|
17
|
+
// `moveSpeed`, a delta is a step in world units. `rise` moves along
|
|
18
|
+
// world up at the same speed, in fly mode only. Walking flattens the
|
|
19
|
+
// forward vector onto the ground plane at fixed height; flying moves
|
|
20
|
+
// along the view direction.
|
|
21
|
+
//
|
|
22
|
+
// Pose is plain mutable state; a nudge or a verb pushes it at once (a
|
|
23
|
+
// mouse move under lock needs no frame loop), and update(dt) integrates
|
|
24
|
+
// the rates from the app's own onFrame. Only `active` is reactive: the
|
|
25
|
+
// frame-loop gate, true while any rate reads non-zero (a held key, a
|
|
26
|
+
// deflected stick), so a still scene renders nothing new. update()
|
|
27
|
+
// returns whether the pose changed since the previous update.
|
|
28
|
+
//
|
|
29
|
+
// Collision is deliberately absent: a camera control cannot know the
|
|
30
|
+
// level. Clamp or reject positions through `clampPosition`.
|
|
31
|
+
//
|
|
32
|
+
// Options are read where they apply, not copied out: `fly`, `moveSpeed`,
|
|
33
|
+
// `lookSpeed`, the pitch clamps and `clampPosition` are re-read from the
|
|
34
|
+
// options object on every input or update, so a caller may change a field
|
|
35
|
+
// (or hand in an object of getters, which is what `<FirstPersonCamera>`
|
|
36
|
+
// does with its props) and the next move sees it - walk and fly are one
|
|
37
|
+
// control, not two mounts. Only the initial pose is copied at creation;
|
|
38
|
+
// later pose changes go through set() and the verbs.
|
|
39
|
+
|
|
40
|
+
import { untrack } from "@solidjs/signals"
|
|
41
|
+
import { createAxes } from "@solidrt/core/input"
|
|
42
|
+
import type { Axes, Vec2 } from "@solidrt/core/input"
|
|
43
|
+
import type { CameraUpdate } from "./camera.ts"
|
|
44
|
+
import type { Vec3 } from "./math.ts"
|
|
45
|
+
|
|
46
|
+
// One element height of drag sweeps this many turns of look (half a turn:
|
|
47
|
+
// a drag across the screen turns the walker around).
|
|
48
|
+
const DRAG_TURNS = 0.5
|
|
49
|
+
// Look rate at full stick deflection, turns per second.
|
|
50
|
+
const LOOK_RATE = 0.4
|
|
51
|
+
// Default walking speed, world units per second.
|
|
52
|
+
const MOVE_SPEED = 3
|
|
53
|
+
// Pitch clamps stop short of the poles so the look direction never
|
|
54
|
+
// degenerates against world up.
|
|
55
|
+
const PITCH_LIMIT = Math.PI / 2 - 0.01
|
|
56
|
+
|
|
57
|
+
/** What a first-person camera drives: a Scene, or one of its Views. */
|
|
58
|
+
export type FirstPersonTarget = { setCamera(update: CameraUpdate): void }
|
|
59
|
+
|
|
60
|
+
/** The pose fields (position, yaw, pitch) are initial values, copied at
|
|
61
|
+
* creation and changed through set() afterwards. Every other field is
|
|
62
|
+
* live: read from this object where it applies, so a change takes effect
|
|
63
|
+
* on the next input or update. */
|
|
64
|
+
export type FirstPersonCameraOptions = {
|
|
65
|
+
/** Initial eye position (default [0, 1.6, 0]: standing height at the
|
|
66
|
+
* origin). */
|
|
67
|
+
position?: Vec3
|
|
68
|
+
/** Initial look, radians. Yaw 0 faces -z (the camera default), positive
|
|
69
|
+
* turns left; pitch 0 is level, positive looks up. */
|
|
70
|
+
yaw?: number
|
|
71
|
+
pitch?: number
|
|
72
|
+
/** Pitch clamps, radians; the defaults stop just short of the poles. */
|
|
73
|
+
minPitch?: number
|
|
74
|
+
maxPitch?: number
|
|
75
|
+
/** Movement speed in world units per second (default 3). */
|
|
76
|
+
moveSpeed?: number
|
|
77
|
+
/** Multiplier over the built-in look sensitivities (drags, mouse motion
|
|
78
|
+
* and rates alike). */
|
|
79
|
+
lookSpeed?: number
|
|
80
|
+
/** Walk (default) keeps the height fixed and moves along the ground
|
|
81
|
+
* projection of the view; fly moves along the view itself and lets
|
|
82
|
+
* `rise` move along world up. Toggling it on a running control keeps
|
|
83
|
+
* the pose. */
|
|
84
|
+
fly?: boolean
|
|
85
|
+
/** Constrain where a move may put the eye: called with the eye the
|
|
86
|
+
* move asks for and the eye it starts from (both fresh arrays), returns
|
|
87
|
+
* the position to use - a level's bounds, a floor height, a collision
|
|
88
|
+
* controller's `moveAndSlide` over the difference. Look does not
|
|
89
|
+
* consult it. */
|
|
90
|
+
clampPosition?: (next: Vec3, current: Vec3) => Vec3
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type FirstPersonPose = {
|
|
94
|
+
position?: Vec3
|
|
95
|
+
yaw?: number
|
|
96
|
+
pitch?: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type FirstPersonAxes = { look: "vec2"; move: "vec2"; rise: "axis" }
|
|
100
|
+
|
|
101
|
+
export type FirstPersonCamera = {
|
|
102
|
+
/** Eye position (a fresh array per call). */
|
|
103
|
+
eye(): Vec3
|
|
104
|
+
/** Unit look direction for the current pose (a fresh array per call). */
|
|
105
|
+
forward(): Vec3
|
|
106
|
+
/** Pose snapshot - the shape debug commands return and set() takes. */
|
|
107
|
+
pose(): { position: Vec3; yaw: number; pitch: number }
|
|
108
|
+
/** Merge a pose in (clamps apply) and push it. */
|
|
109
|
+
set(pose: FirstPersonPose): void
|
|
110
|
+
/** Whether any axis rate reads non-zero - what a frame loop should run
|
|
111
|
+
* on. Reactive. */
|
|
112
|
+
active(): boolean
|
|
113
|
+
/** Integrate the axis rates over dt seconds and push any pose change;
|
|
114
|
+
* returns whether the pose changed since the previous update (nudges
|
|
115
|
+
* and verbs included). */
|
|
116
|
+
update(dt: number): boolean
|
|
117
|
+
/** The input abstraction: `look` (vec2, element heights of drag / turns
|
|
118
|
+
* per second), `move` (vec2 [right, forward], forward = -y; world units
|
|
119
|
+
* per delta, `moveSpeed` per second) and `rise` (axis, fly only). */
|
|
120
|
+
axes: Axes<FirstPersonAxes>
|
|
121
|
+
/** Turn by radians (yaw positive left, pitch positive up; clamps
|
|
122
|
+
* apply) and push. */
|
|
123
|
+
lookBy(yaw: number, pitch: number): void
|
|
124
|
+
/** Step by world units in the walker's frame: right, forward (ground
|
|
125
|
+
* projection when walking, the view direction when flying) and up
|
|
126
|
+
* (world up, fly mode only), through `clampPosition`, and push. */
|
|
127
|
+
moveBy(right: number, forward: number, up?: number): void
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
|
|
131
|
+
|
|
132
|
+
function finite(what: string, v: number): void {
|
|
133
|
+
if (!Number.isFinite(v)) throw new Error(`createFirstPersonCamera: ${what} must be a finite number, got ${v}`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Create a first-person camera driving `camera`'s position and target,
|
|
138
|
+
* where `camera` is a Scene or one of its Views (fov, near, and far stay
|
|
139
|
+
* yours via its setCamera). The initial pose applies immediately. In a
|
|
140
|
+
* component tree, prefer the `<FirstPersonCamera>` component: it drives
|
|
141
|
+
* the enclosing scene or view through context and takes an input map as
|
|
142
|
+
* a prop.
|
|
143
|
+
*/
|
|
144
|
+
export function createFirstPersonCamera(camera: FirstPersonTarget, options: FirstPersonCameraOptions = {}): FirstPersonCamera {
|
|
145
|
+
if (!camera || typeof camera.setCamera !== "function") throw new Error("createFirstPersonCamera: the target needs setCamera() (a Scene or a View)")
|
|
146
|
+
let position: Vec3 = options.position ? [options.position[0], options.position[1], options.position[2]] : [0, 1.6, 0]
|
|
147
|
+
let yaw = options.yaw ?? 0
|
|
148
|
+
let pitch = options.pitch ?? 0
|
|
149
|
+
// Everything below the pose is read from `options` where it applies.
|
|
150
|
+
let lookSpeed = () => options.lookSpeed ?? 1
|
|
151
|
+
let moveSpeed = () => options.moveSpeed ?? MOVE_SPEED
|
|
152
|
+
let clampPitch = () => {
|
|
153
|
+
pitch = clampNum(pitch, options.minPitch ?? -PITCH_LIMIT, options.maxPitch ?? PITCH_LIMIT)
|
|
154
|
+
}
|
|
155
|
+
let changed = false
|
|
156
|
+
|
|
157
|
+
let forward = (): Vec3 => {
|
|
158
|
+
let cp = Math.cos(pitch)
|
|
159
|
+
return [-Math.sin(yaw) * cp, Math.sin(pitch), -Math.cos(yaw) * cp]
|
|
160
|
+
}
|
|
161
|
+
let push = () => {
|
|
162
|
+
changed = true
|
|
163
|
+
let f = forward()
|
|
164
|
+
camera.setCamera({ position: [position[0], position[1], position[2]], target: [position[0] + f[0], position[1] + f[1], position[2] + f[2]] })
|
|
165
|
+
}
|
|
166
|
+
let look = (dYaw: number, dPitch: number) => {
|
|
167
|
+
yaw += dYaw
|
|
168
|
+
pitch += dPitch
|
|
169
|
+
clampPitch()
|
|
170
|
+
}
|
|
171
|
+
// A step in the walker's frame. Walking projects the view onto the
|
|
172
|
+
// ground plane, so looking down does not slow the walk; right is always
|
|
173
|
+
// horizontal; up applies in fly mode only.
|
|
174
|
+
let step = (right: number, ahead: number, up: number) => {
|
|
175
|
+
let fly = options.fly ?? false
|
|
176
|
+
let f = forward()
|
|
177
|
+
let fx = fly ? f[0] : -Math.sin(yaw)
|
|
178
|
+
let fy = fly ? f[1] : 0
|
|
179
|
+
let fz = fly ? f[2] : -Math.cos(yaw)
|
|
180
|
+
let rx = Math.cos(yaw)
|
|
181
|
+
let rz = -Math.sin(yaw)
|
|
182
|
+
let rise = fly ? up : 0
|
|
183
|
+
let next: Vec3 = [position[0] + fx * ahead + rx * right, position[1] + fy * ahead + rise, position[2] + fz * ahead + rz * right]
|
|
184
|
+
position = options.clampPosition ? options.clampPosition(next, [position[0], position[1], position[2]]) : next
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let axes = createAxes<FirstPersonAxes>(
|
|
188
|
+
{ look: "vec2", move: "vec2", rise: "axis" },
|
|
189
|
+
{
|
|
190
|
+
onNudge: (name, delta) => {
|
|
191
|
+
if (name === "look") {
|
|
192
|
+
let d = delta as Vec2
|
|
193
|
+
let rel = DRAG_TURNS * 2 * Math.PI * lookSpeed()
|
|
194
|
+
look(-d[0] * rel, -d[1] * rel)
|
|
195
|
+
} else if (name === "move") {
|
|
196
|
+
let d = delta as Vec2
|
|
197
|
+
step(d[0], -d[1], 0)
|
|
198
|
+
} else {
|
|
199
|
+
step(0, 0, delta as number)
|
|
200
|
+
}
|
|
201
|
+
push()
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
clampPitch()
|
|
207
|
+
push()
|
|
208
|
+
changed = false
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
eye: () => [position[0], position[1], position[2]],
|
|
212
|
+
forward,
|
|
213
|
+
pose: () => ({ position: [position[0], position[1], position[2]], yaw, pitch }),
|
|
214
|
+
active: axes.active,
|
|
215
|
+
axes,
|
|
216
|
+
set(pose) {
|
|
217
|
+
if (pose.position) position = [pose.position[0], pose.position[1], pose.position[2]]
|
|
218
|
+
if (pose.yaw !== undefined) yaw = pose.yaw
|
|
219
|
+
if (pose.pitch !== undefined) pitch = pose.pitch
|
|
220
|
+
clampPitch()
|
|
221
|
+
push()
|
|
222
|
+
},
|
|
223
|
+
update(dt) {
|
|
224
|
+
finite("update dt", dt)
|
|
225
|
+
let moved = false
|
|
226
|
+
let [lx, ly] = untrack(() => axes.rate("look"))
|
|
227
|
+
if (lx !== 0 || ly !== 0) {
|
|
228
|
+
let rate = LOOK_RATE * 2 * Math.PI * lookSpeed() * dt
|
|
229
|
+
look(-lx * rate, -ly * rate)
|
|
230
|
+
moved = true
|
|
231
|
+
}
|
|
232
|
+
let [mx, my] = untrack(() => axes.rate("move"))
|
|
233
|
+
let rise = untrack(() => axes.rate("rise"))
|
|
234
|
+
if (mx !== 0 || my !== 0 || rise !== 0) {
|
|
235
|
+
let speed = moveSpeed() * dt
|
|
236
|
+
step(mx * speed, -my * speed, rise * speed)
|
|
237
|
+
moved = true
|
|
238
|
+
}
|
|
239
|
+
if (moved) push()
|
|
240
|
+
let result = changed
|
|
241
|
+
changed = false
|
|
242
|
+
return result
|
|
243
|
+
},
|
|
244
|
+
lookBy(dYaw, dPitch) {
|
|
245
|
+
finite("lookBy yaw", dYaw)
|
|
246
|
+
finite("lookBy pitch", dPitch)
|
|
247
|
+
look(dYaw, dPitch)
|
|
248
|
+
push()
|
|
249
|
+
},
|
|
250
|
+
moveBy(right, ahead, up = 0) {
|
|
251
|
+
finite("moveBy right", right)
|
|
252
|
+
finite("moveBy forward", ahead)
|
|
253
|
+
finite("moveBy up", up)
|
|
254
|
+
step(right, ahead, up)
|
|
255
|
+
push()
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
}
|
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
|
|