@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
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ function App() {
|
|
|
15
15
|
onFrame(tick => setSpin(tick / 2000))
|
|
16
16
|
return (
|
|
17
17
|
<window>
|
|
18
|
-
<Scene
|
|
18
|
+
<Scene>
|
|
19
19
|
<PerspectiveCamera position={[0, 1.5, 3]} lookAt={[0, 0, 0]} />
|
|
20
20
|
<Mesh geometry={box()} material={unlit({ color: [0.9, 0.3, 0.3] })} rotation={[0, spin(), 0]} />
|
|
21
21
|
</Scene>
|
|
@@ -30,7 +30,9 @@ per mesh, one shared pipeline per material class, cross-mesh occlusion
|
|
|
30
30
|
from the shared depth buffer. A static scene costs zero GPU passes - the
|
|
31
31
|
runtime re-renders the target only when something changes - and a moved
|
|
32
32
|
mesh costs one uniform write. By default `<Scene>` composites the target
|
|
33
|
-
as a plain `<texture>` leaf
|
|
33
|
+
as a plain `<texture>` leaf that fills its parent's box, rendered at the
|
|
34
|
+
leaf's on-screen device-pixel size (give `width`/`height` for a fixed
|
|
35
|
+
target instead); the `output` prop receives the texture id
|
|
34
36
|
and replaces that leaf - place a `<d-texture>`, add paint or pointer
|
|
35
37
|
props, chain a post-effect shader target, or return null and composite
|
|
36
38
|
`scene.texture` yourself.
|
|
@@ -68,10 +70,15 @@ however many meshes read it), or per mesh, declaratively via the `params`
|
|
|
68
70
|
prop on `<Mesh>` or imperatively via `setMeshParams`. `shaderMaterialClass`
|
|
69
71
|
compiles one program and hands out `instance()` materials that differ only
|
|
70
72
|
in params/textures - and with `instanceAttributes` it makes an instanced
|
|
71
|
-
material: `<InstancedMesh
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
material. Populations come in two forms: `<InstancedMesh>` with
|
|
74
|
+
`<Instance>` children (or `createInstancedMesh`/`addInstance`) draws the
|
|
75
|
+
geometry once per instance NODE - each a scene node the spatial core
|
|
76
|
+
places, so transitions, picking and pointer events reach every copy with
|
|
77
|
+
no per-frame JS, and the stock materials' `instanced`/`instanceColors`
|
|
78
|
+
give it lighting, shadows and a per-instance tint without GLSL
|
|
79
|
+
(`examples/fleet.tsx`); `<RecordMesh records>` (or `createRecordMesh`)
|
|
80
|
+
draws once per interleaved JS-written record, the shape for particles and
|
|
81
|
+
every fleet only JS can step (`examples/instanced.tsx`). And
|
|
75
82
|
`@solidrt/3d/glsl` exports the lighting pieces (hemisphere, lambert,
|
|
76
83
|
blinn, fresnel, a standard vertex stage) to compose your own lit looks
|
|
77
84
|
from plain template literals.
|
|
@@ -89,7 +96,8 @@ one mesh per material), open vertex layouts (`withAttribute` appends any
|
|
|
89
96
|
named channel to a geometry's interleave, `withColors` is the `aColor`
|
|
90
97
|
vec4 case - tint, baked AO, any four scalars - and materials read channels
|
|
91
98
|
by name), one perspective camera
|
|
92
|
-
with an orbit control (
|
|
99
|
+
with an orbit control (`<OrbitCamera>` as a Scene child, or
|
|
100
|
+
`createOrbitCamera` imperatively: drag, pinch/wheel zoom, auto-orbit),
|
|
93
101
|
mesh picking with pointer events, scene backgrounds, transparency, and
|
|
94
102
|
lights (`lit` material with hemisphere ambient, up to four directional
|
|
95
103
|
light nodes, Blinn-Phong highlight and triplanar mapping).
|
package/examples/README.md
CHANGED
|
@@ -29,15 +29,76 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
|
|
|
29
29
|
scene's own pass (`<Scene background>`): one target, no stacked
|
|
30
30
|
backdrop texture, no resize plumbing; the source is shader-target
|
|
31
31
|
compatible verbatim.
|
|
32
|
+
- `skybox.tsx` - a cube-map skybox (`<Scene background={{ cube,
|
|
33
|
+
rotation }}>`) baked in JS at startup (horizon gradient, sun disc and
|
|
34
|
+
glow, no image assets), and the same cube as the environment
|
|
35
|
+
(`environment={{ cube, rotation }}`) mirrored by a chrome sphere
|
|
36
|
+
(`lit({ reflectivity: 1 })`) and, blurred by shininess, a glossy knot.
|
|
37
|
+
The sky turns and the sun light turns with it, the `rotation` knobs
|
|
38
|
+
updating in place; drag to look around.
|
|
39
|
+
- `standard.tsx` - the `standard` material, the sphere grid every PBR
|
|
40
|
+
engine opens with: metalness down the rows, roughness across the
|
|
41
|
+
columns, under the skybox example's baked sky as background and
|
|
42
|
+
environment and a warm sun casting shadows; a bare-metal row shows
|
|
43
|
+
the environment as tinted reflections, the dielectric row the same
|
|
44
|
+
sky as a faint face-on gloss. Drag to look around.
|
|
45
|
+
- `environment.tsx` - a baked HDR environment lighting the scene ALONE
|
|
46
|
+
(no lights, no hemisphere): `loadEnvironment` reads the .srte that
|
|
47
|
+
`bunx srt tool 3d/environment <panorama>.hdr -o assets/environment.srte`
|
|
48
|
+
bakes from any equirectangular .hdr (Poly Haven's are CC0; the asset
|
|
49
|
+
is not committed, bake one first), used as background and environment
|
|
50
|
+
with ACES tone mapping. A metal row and a red dielectric row, roughness
|
|
51
|
+
0 to 1 across: the metals are the room, sharp to blurred. Drag to
|
|
52
|
+
look around.
|
|
53
|
+
- `probe.tsx` - a reflection probe (`scene.createReflectionProbe`): the
|
|
54
|
+
scene rendered into a cube map from the center of a satin chrome ball,
|
|
55
|
+
every frame, prefiltered into the roughness chain and set as the
|
|
56
|
+
environment the ball reflects - six colored walls and four orbiting
|
|
57
|
+
spheres show in it, blurred by the ball's roughness. Layers keep the
|
|
58
|
+
ball out of its own probe. Drag to look around.
|
|
59
|
+
- `sky-lit.tsx` - a sky-lit scene (`scene.bakeBackground`): a procedural
|
|
60
|
+
GLSL sky is the background AND, baked into a prefiltered cube, the
|
|
61
|
+
environment - no light nodes. Two rows of `standard` spheres, metal and
|
|
62
|
+
dielectric, roughness 0 to 1 across; the sun disc bakes at 40x (the
|
|
63
|
+
probe format is half float where the device renders it), so the rough
|
|
64
|
+
metals carry its energy as a broad highlight. Drag to look around.
|
|
32
65
|
- `sprites.tsx` - sprites: a ring of `"full"` billboard glows that stay
|
|
33
66
|
flat to the screen and `"fixed-y"` cutout trees that only yaw toward
|
|
34
67
|
the camera and stay upright as it climbs, both turned in the vertex
|
|
35
68
|
stage while the camera circles - no per-sprite JS per frame.
|
|
36
|
-
- `instanced.tsx` -
|
|
37
|
-
`instanceAttributes`, two `<
|
|
38
|
-
rocks, a ring of pines) each ONE draw entry and
|
|
39
|
-
group moving both with two matrix writes, and
|
|
40
|
-
onFrame breathing the pine population.
|
|
69
|
+
- `instanced.tsx` - record meshes, the JS-written population: one
|
|
70
|
+
material class declaring `instanceAttributes`, two `<RecordMesh>`
|
|
71
|
+
fleets (400 scattered rocks, a ring of pines) each ONE draw entry and
|
|
72
|
+
ONE uModel, a spinning group moving both with two matrix writes, and
|
|
73
|
+
`setRecordCount` from onFrame breathing the pine population. The class
|
|
74
|
+
also declares `shadowVertex` (the placement math alone), so both
|
|
75
|
+
fleets `castShadow` onto the lit ground - the pines' shadows breathe
|
|
76
|
+
with them.
|
|
77
|
+
- `fleet.tsx` - instanced meshes, the node-backed population: one
|
|
78
|
+
`<InstancedMesh>` of a thousand `<Instance>` nodes under the stock
|
|
79
|
+
`lit` material with `instanceColors` (lighting, shadows, fog and a
|
|
80
|
+
per-instance tint with no GLSL). Every few seconds a signal picks the
|
|
81
|
+
next formation and the core springs every instance to its place
|
|
82
|
+
through its `transition` - one signal write, zero JS per frame after
|
|
83
|
+
it; a tap flips the struck instance's tint (pointer events name the
|
|
84
|
+
instance; every style write between two frames is one buffer write).
|
|
85
|
+
Drag to orbit; the `formation` and `state` debug commands drive it
|
|
86
|
+
headlessly.
|
|
87
|
+
- `first-person.tsx` - a first-person walk: `<FirstPersonCamera>` over
|
|
88
|
+
a walled courtyard of shadow-casting pillars, WASD/arrows and the pad
|
|
89
|
+
sticks to walk, mouse look under pointer lock (click locks, Escape
|
|
90
|
+
releases - the app's calls, not the control's), a drag to look on
|
|
91
|
+
touch; `clampPosition` keeps the walker inside the walls, the whole of
|
|
92
|
+
the collision a camera control offers; the `pose` debug command reads
|
|
93
|
+
and sets the pose headlessly.
|
|
94
|
+
- `collision.tsx` - collision without a physics engine: the same walker
|
|
95
|
+
as a capsule through `moveAndSlide` over the scene's sweep queries,
|
|
96
|
+
in a level of walls, a ramp, a platform with a ledge and pillars whose
|
|
97
|
+
drawn meshes carry the collider layer bit too; gravity as a frame loop
|
|
98
|
+
that runs only while airborne (Space jumps), pickups lit by one
|
|
99
|
+
`overlap` per move. Debug commands `walk` (frame-sized steps through
|
|
100
|
+
the collision), `jump`, `fall` (one airborne step by hand) and `state`
|
|
101
|
+
drive it headlessly.
|
|
41
102
|
- `scene-views.tsx` - scene views: one scene rendered three times, the
|
|
42
103
|
built-in perspective leaf plus two `scene.createView` targets - a
|
|
43
104
|
top-down ORTHOGRAPHIC map (`ortho` on setCamera) and a side silhouette
|
|
@@ -58,6 +119,12 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
|
|
|
58
119
|
the plain box widened to cover the field: one map's texels spread over
|
|
59
120
|
it, blocky everywhere) and the `cascades`/`fly` debug commands set the
|
|
60
121
|
count and the shadow distance and park the flight.
|
|
122
|
+
- `lamps.tsx` - spot and point lights in a dark courtyard: a warm SOFT
|
|
123
|
+
spot (penumbra 0.4) swinging from a parent group, a fixed HARD spot
|
|
124
|
+
(penumbra 0.05) aimed by `direction` at a knot on a pedestal, and a
|
|
125
|
+
blue point bulb orbiting the crates; both spots `castShadow` (a
|
|
126
|
+
perspective map each), and the header notes the decay-2 intensity
|
|
127
|
+
scale (candela-like: a lamp 5 units up wants ~40, not ~2).
|
|
61
128
|
- `fog.tsx` - scene fog over a valley of pines between two ridges: a
|
|
62
129
|
click cycles LINEAR (`{ near, far }`, a clear band then a fade to the
|
|
63
130
|
far plane), EXP2 (`{ density }`, thickening from the first metre) and
|
|
@@ -70,3 +137,10 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
|
|
|
70
137
|
transparent dome and a mesh without normals) parsed with `parseGltf`
|
|
71
138
|
from a binary import and built by `createModel` into a Group of named
|
|
72
139
|
parts; clicking a part hides it, clicking the body restores all.
|
|
140
|
+
- `model-load.tsx` - the same rover loaded ASYNC with `loadModel` from
|
|
141
|
+
`assets/` under a `<Loading>` boundary: the async read lives in a memo,
|
|
142
|
+
a second memo derives the scene JSX after that read, and the shell
|
|
143
|
+
stays above the boundary - the two rules that avoid
|
|
144
|
+
PENDING_ASYNC_UNTRACKED_READ and the suspend-retry element leak. Needs
|
|
145
|
+
the asset baked into the running app:
|
|
146
|
+
`bunx srt tool 3d/model examples/model.glb -o assets/model.srtm`.
|
package/examples/aim.tsx
CHANGED
|
@@ -25,7 +25,6 @@ import {
|
|
|
25
25
|
} from "@solidrt/3d"
|
|
26
26
|
import type { MeshNode, Vec3 } from "@solidrt/3d"
|
|
27
27
|
|
|
28
|
-
const SIZE = 720
|
|
29
28
|
const Y_AXIS: Vec3 = [0, 1, 0]
|
|
30
29
|
|
|
31
30
|
function App() {
|
|
@@ -79,8 +78,8 @@ function App() {
|
|
|
79
78
|
|
|
80
79
|
return (
|
|
81
80
|
<window>
|
|
82
|
-
<view width={pct(100)} height={pct(100)}
|
|
83
|
-
<Scene
|
|
81
|
+
<view width={pct(100)} height={pct(100)}>
|
|
82
|
+
<Scene clearColor={[0.07, 0.07, 0.1, 1]} label="aim">
|
|
84
83
|
<PerspectiveCamera fov={55} position={[0, 2.6, 4.6]} lookAt={[0, 0.7, 0]} />
|
|
85
84
|
<Mesh
|
|
86
85
|
geometry={plane({ width: 7, height: 7, label: "floor" })}
|
package/examples/cascades.tsx
CHANGED
|
@@ -23,7 +23,6 @@ import { registerDebug } from "srt:dev"
|
|
|
23
23
|
import { box, DirectionalLight, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene, sphere } from "@solidrt/3d"
|
|
24
24
|
import type { Geometry, Vec3 } from "@solidrt/3d"
|
|
25
25
|
|
|
26
|
-
const SIZE = 720
|
|
27
26
|
const FIELD = 260
|
|
28
27
|
const FAR = 200
|
|
29
28
|
// The flight: a circle over the field, looking ahead along it.
|
|
@@ -97,10 +96,8 @@ function App() {
|
|
|
97
96
|
|
|
98
97
|
return (
|
|
99
98
|
<window>
|
|
100
|
-
<view width={pct(100)} height={pct(100)}
|
|
99
|
+
<view width={pct(100)} height={pct(100)} onPointerDown={() => setCascades(c => (c % 4) + 1)}>
|
|
101
100
|
<Scene
|
|
102
|
-
width={SIZE}
|
|
103
|
-
height={SIZE}
|
|
104
101
|
clearColor={[SKY[0], SKY[1], SKY[2], 1]}
|
|
105
102
|
fog={{ color: SKY, near: FOG_NEAR, far: FOG_FAR }}
|
|
106
103
|
samples={4}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Collision without a physics engine: a capsule-controlled first-person
|
|
2
|
+
// walker in a level of walls, a ramp, a platform with a ledge and a few
|
|
3
|
+
// pillars, all colliding through `moveAndSlide` over the scene's sweep
|
|
4
|
+
// queries. The level meshes carry the COLLIDER layer bit beside the drawn
|
|
5
|
+
// one, so the same geometry draws and collides (a low-poly stand-in would
|
|
6
|
+
// carry COLLIDER alone and stay undrawn); the pickups do not, so the
|
|
7
|
+
// walker passes through them, and one `overlap` per move lights the ones
|
|
8
|
+
// within reach. Walking goes through the camera's `clampPosition` hook;
|
|
9
|
+
// gravity runs a frame loop only while airborne (a jump, a ledge), so a
|
|
10
|
+
// standing scene renders nothing new. WASD/arrows walk, Space jumps,
|
|
11
|
+
// click locks the pointer, Escape releases it. Debug commands: `pose`
|
|
12
|
+
// (read/set), `walk` ({ dx, dz } through the collision), `jump`, `fall`
|
|
13
|
+
// ({ dt }: one airborne step by hand, for headless checks), `state`.
|
|
14
|
+
import { createSignal, lockPointer, onFrame, pct, pointerLocked, render } from "@solidrt/core"
|
|
15
|
+
import type { PointerEvent } from "@solidrt/core"
|
|
16
|
+
import {
|
|
17
|
+
box,
|
|
18
|
+
cylinder,
|
|
19
|
+
DirectionalLight,
|
|
20
|
+
FirstPersonCamera,
|
|
21
|
+
HemisphereLight,
|
|
22
|
+
lit,
|
|
23
|
+
Mesh,
|
|
24
|
+
moveAndSlide,
|
|
25
|
+
PerspectiveCamera,
|
|
26
|
+
plane,
|
|
27
|
+
Scene,
|
|
28
|
+
sphere,
|
|
29
|
+
} from "@solidrt/3d"
|
|
30
|
+
import type { FirstPersonCameraHandle, MeshNode, MoveResult, SceneHandle, Vec3, Volume } from "@solidrt/3d"
|
|
31
|
+
import { registerDebug } from "srt:dev"
|
|
32
|
+
|
|
33
|
+
// Layer bit the level geometry adds to its drawn layer: what every
|
|
34
|
+
// collision query selects, so pickups (drawn only) never block the walker.
|
|
35
|
+
const COLLIDER = 2
|
|
36
|
+
// The walker: eye height over the feet, the capsule's radius, and where
|
|
37
|
+
// its segment sits relative to the eye (feet + radius up to a hand under
|
|
38
|
+
// the eye).
|
|
39
|
+
const EYE = 1.6
|
|
40
|
+
const RADIUS = 0.3
|
|
41
|
+
const BODY_LOW = -EYE + RADIUS
|
|
42
|
+
const BODY_HIGH = -0.2
|
|
43
|
+
// Vertical dynamics, world units per second (squared for gravity).
|
|
44
|
+
const GRAVITY = 12
|
|
45
|
+
const JUMP_SPEED = 5
|
|
46
|
+
// Largest frame step the fall integrates: a stall must not tunnel the
|
|
47
|
+
// walker through a floor.
|
|
48
|
+
const MAX_FALL_DT = 0.05
|
|
49
|
+
// A pickup lights up within this distance of the feet.
|
|
50
|
+
const REACH = 1.5
|
|
51
|
+
// Level dimensions: floor half extent, wall height, the platform's top
|
|
52
|
+
// and footprint, the ramp's run.
|
|
53
|
+
const FLOOR = 15
|
|
54
|
+
const WALL_HEIGHT = 3
|
|
55
|
+
const PLATFORM_TOP = 1.5
|
|
56
|
+
const PLATFORM_HALF = 3
|
|
57
|
+
const RAMP_RUN = 6
|
|
58
|
+
const RAMP_THICKNESS = 0.3
|
|
59
|
+
// HUD refresh interval in ms.
|
|
60
|
+
const HUD_MS = 100
|
|
61
|
+
// One frame of walking at the default speed, the stride the `walk` debug
|
|
62
|
+
// command takes so its checks match live play.
|
|
63
|
+
const WALK_STEP = 0.05
|
|
64
|
+
|
|
65
|
+
let bodyAt = (eye: Vec3): Volume => ({ a: [eye[0], eye[1] + BODY_LOW, eye[2]], b: [eye[0], eye[1] + BODY_HIGH, eye[2]], radius: RADIUS })
|
|
66
|
+
let feetOf = (eye: Vec3): Vec3 => [eye[0], eye[1] - EYE, eye[2]]
|
|
67
|
+
|
|
68
|
+
function App() {
|
|
69
|
+
let scene!: SceneHandle
|
|
70
|
+
let camera!: FirstPersonCameraHandle
|
|
71
|
+
let [hud, setHud] = createSignal("")
|
|
72
|
+
let [near, setNear] = createSignal<Set<number>>(new Set())
|
|
73
|
+
// The same set, readable synchronously (a signal read right after its
|
|
74
|
+
// write still returns the old value): what the debug commands report.
|
|
75
|
+
let nearNow = new Set<number>()
|
|
76
|
+
let grounded = true
|
|
77
|
+
let fallSpeed = 0
|
|
78
|
+
let stopFall: (() => void) | null = null
|
|
79
|
+
let pickups: MeshNode[] = []
|
|
80
|
+
|
|
81
|
+
let dim = lit({ color: [0.55, 0.45, 0.2] })
|
|
82
|
+
let bright = lit({ color: [1, 0.85, 0.3], specular: 0.8, shininess: 60 })
|
|
83
|
+
let ground = lit({ color: [0.48, 0.52, 0.46] })
|
|
84
|
+
let stone = lit({ color: [0.72, 0.7, 0.66] })
|
|
85
|
+
let brick = lit({ color: [0.6, 0.34, 0.28] })
|
|
86
|
+
|
|
87
|
+
// Every move ends here: the walker's new eye, the ground state, and the
|
|
88
|
+
// pickups in reach of its feet (an include-list query, so the level
|
|
89
|
+
// itself never reports).
|
|
90
|
+
let settle = (eye: Vec3, floor: Vec3 | null) => {
|
|
91
|
+
grounded = floor !== null
|
|
92
|
+
let hit = new Set<number>()
|
|
93
|
+
for (let o of scene.overlap({ center: feetOf(eye), radius: REACH }, { meshes: pickups })) hit.add(pickups.indexOf(o.mesh))
|
|
94
|
+
nearNow = hit
|
|
95
|
+
setNear(hit)
|
|
96
|
+
}
|
|
97
|
+
// Walking: the camera asks for `next` from `eye`, the collision answers.
|
|
98
|
+
let clamp = (next: Vec3, eye: Vec3): Vec3 => {
|
|
99
|
+
let r = moveAndSlide(scene, bodyAt(eye), [next[0] - eye[0], next[1] - eye[1], next[2] - eye[2]], { layers: COLLIDER })
|
|
100
|
+
let moved: Vec3 = [eye[0] + r.motion[0], eye[1] + r.motion[1], eye[2] + r.motion[2]]
|
|
101
|
+
settle(moved, r.floor)
|
|
102
|
+
if (!grounded) fall()
|
|
103
|
+
return moved
|
|
104
|
+
}
|
|
105
|
+
// One airborne step: gravity into the vertical speed, the drop through
|
|
106
|
+
// the collision, a ceiling bonk kills the rise, a floor ends the fall.
|
|
107
|
+
let fallStep = (dt: number) => {
|
|
108
|
+
fallSpeed -= GRAVITY * dt
|
|
109
|
+
let eye = camera.pose().position
|
|
110
|
+
let r = moveAndSlide(scene, bodyAt(eye), [0, fallSpeed * dt, 0], { layers: COLLIDER })
|
|
111
|
+
let moved: Vec3 = [eye[0] + r.motion[0], eye[1] + r.motion[1], eye[2] + r.motion[2]]
|
|
112
|
+
camera.set({ position: moved })
|
|
113
|
+
if (r.ceiling && fallSpeed > 0) fallSpeed = 0
|
|
114
|
+
settle(moved, r.floor)
|
|
115
|
+
if (grounded) {
|
|
116
|
+
fallSpeed = 0
|
|
117
|
+
stopFall?.()
|
|
118
|
+
stopFall = null
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Airborne: step the fall frame by frame until a floor takes it.
|
|
122
|
+
let fall = () => {
|
|
123
|
+
if (stopFall !== null) return
|
|
124
|
+
let last: number | null = null
|
|
125
|
+
stopFall = onFrame(tick => {
|
|
126
|
+
let now = tick / 1000
|
|
127
|
+
let dt = last === null ? 0 : Math.max(0, Math.min(now - last, MAX_FALL_DT))
|
|
128
|
+
last = now
|
|
129
|
+
fallStep(dt)
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
let jump = () => {
|
|
133
|
+
if (!grounded) return
|
|
134
|
+
fallSpeed = JUMP_SPEED
|
|
135
|
+
grounded = false
|
|
136
|
+
fall()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let lastHud = 0
|
|
140
|
+
onFrame(tick => {
|
|
141
|
+
if (tick - lastHud < HUD_MS) return
|
|
142
|
+
lastHud = tick
|
|
143
|
+
let p = camera.pose().position
|
|
144
|
+
setHud(
|
|
145
|
+
`${pointerLocked() ? "locked - Escape releases" : "click to lock"} WASD walk, Space jump x ${p[0].toFixed(1)} y ${(p[1] - EYE).toFixed(2)} z ${p[2].toFixed(1)} ${grounded ? "grounded" : "airborne"} pickups ${near().size}`,
|
|
146
|
+
)
|
|
147
|
+
})
|
|
148
|
+
registerDebug("pose", (args?: Record<string, unknown>) => {
|
|
149
|
+
if (args) camera.set(args as Parameters<typeof camera.set>[0])
|
|
150
|
+
return camera.pose()
|
|
151
|
+
})
|
|
152
|
+
registerDebug("walk", (args?: { dx?: number; dz?: number; steps?: number }) => {
|
|
153
|
+
// Frame-sized steps by default: a level's ramps and snap distances
|
|
154
|
+
// are tuned for them, not for one long stride.
|
|
155
|
+
let steps = args?.steps ?? Math.max(1, Math.ceil(Math.hypot(args?.dx ?? 0, args?.dz ?? 0) / WALK_STEP))
|
|
156
|
+
let last: MoveResult | null = null
|
|
157
|
+
for (let i = 0; i < steps; i++) {
|
|
158
|
+
let eye = camera.pose().position
|
|
159
|
+
let r = moveAndSlide(scene, bodyAt(eye), [(args?.dx ?? 0) / steps, 0, (args?.dz ?? 0) / steps], { layers: COLLIDER })
|
|
160
|
+
let moved: Vec3 = [eye[0] + r.motion[0], eye[1] + r.motion[1], eye[2] + r.motion[2]]
|
|
161
|
+
settle(moved, r.floor)
|
|
162
|
+
camera.set({ position: moved })
|
|
163
|
+
last = r
|
|
164
|
+
}
|
|
165
|
+
if (!grounded) fall()
|
|
166
|
+
let p = camera.pose().position
|
|
167
|
+
return { position: p, feet: p[1] - EYE, grounded, wall: last?.wall, ceiling: last?.ceiling, hits: last?.hits.length, pickups: [...nearNow].sort() }
|
|
168
|
+
})
|
|
169
|
+
registerDebug("jump", () => {
|
|
170
|
+
jump()
|
|
171
|
+
return { grounded, fallSpeed }
|
|
172
|
+
})
|
|
173
|
+
registerDebug("fall", (args?: { dt?: number }) => {
|
|
174
|
+
if (!grounded) fallStep(args?.dt ?? 1 / 60)
|
|
175
|
+
return { grounded, fallSpeed, position: camera.pose().position }
|
|
176
|
+
})
|
|
177
|
+
registerDebug("state", () => ({ grounded, fallSpeed, pickups: [...nearNow].sort() }))
|
|
178
|
+
|
|
179
|
+
// The ramp: a thin box whose top face runs from the floor at z = +run/2
|
|
180
|
+
// up to the platform's top at z = -run/2, so its rotation about x is the
|
|
181
|
+
// slope and its center sits half a thickness under the top face.
|
|
182
|
+
let slope = Math.atan2(PLATFORM_TOP, RAMP_RUN)
|
|
183
|
+
let rampLength = Math.hypot(RAMP_RUN, PLATFORM_TOP)
|
|
184
|
+
let rampCenter: Vec3 = [0, PLATFORM_TOP / 2 - Math.cos(slope) * (RAMP_THICKNESS / 2), -PLATFORM_HALF - RAMP_RUN / 2 - Math.sin(slope) * (RAMP_THICKNESS / 2)]
|
|
185
|
+
let walls = [
|
|
186
|
+
{ position: [0, WALL_HEIGHT / 2, -FLOOR - 0.5] as Vec3, size: [FLOOR * 2 + 2, WALL_HEIGHT, 1] as Vec3 },
|
|
187
|
+
{ position: [0, WALL_HEIGHT / 2, FLOOR + 0.5] as Vec3, size: [FLOOR * 2 + 2, WALL_HEIGHT, 1] as Vec3 },
|
|
188
|
+
{ position: [-FLOOR - 0.5, WALL_HEIGHT / 2, 0] as Vec3, size: [1, WALL_HEIGHT, FLOOR * 2] as Vec3 },
|
|
189
|
+
{ position: [FLOOR + 0.5, WALL_HEIGHT / 2, 0] as Vec3, size: [1, WALL_HEIGHT, FLOOR * 2] as Vec3 },
|
|
190
|
+
]
|
|
191
|
+
let pillars: Vec3[] = [
|
|
192
|
+
[6, WALL_HEIGHT / 2, 4],
|
|
193
|
+
[-6, WALL_HEIGHT / 2, 4],
|
|
194
|
+
[8, WALL_HEIGHT / 2, -8],
|
|
195
|
+
]
|
|
196
|
+
let gems: Vec3[] = [
|
|
197
|
+
[3, 0.4, 6],
|
|
198
|
+
[-3, 0.4, 2],
|
|
199
|
+
[7, 0.4, -2],
|
|
200
|
+
[-8, 0.4, -6],
|
|
201
|
+
[0, PLATFORM_TOP + 0.4, -PLATFORM_HALF - RAMP_RUN - PLATFORM_HALF],
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<window
|
|
206
|
+
onKeyDown={e => {
|
|
207
|
+
if (e.key === "Escape") lockPointer(false)
|
|
208
|
+
if (e.key === " " || e.code === "Space") jump()
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
<view
|
|
212
|
+
width={pct(100)}
|
|
213
|
+
height={pct(100)}
|
|
214
|
+
onPointerDown={(e: PointerEvent) => {
|
|
215
|
+
if (e.pointerType === "mouse") lockPointer(true)
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
<Scene ref={s => (scene = s)} clearColor={[0.6, 0.72, 0.88, 1]} samples={4} label="collision">
|
|
219
|
+
<PerspectiveCamera fov={70} near={0.1} far={80} />
|
|
220
|
+
<FirstPersonCamera ref={c => (camera = c)} position={[0, EYE, 10]} clampPosition={clamp} />
|
|
221
|
+
<HemisphereLight sky={[0.5, 0.56, 0.68]} ground={[0.25, 0.22, 0.18]} />
|
|
222
|
+
<DirectionalLight color={[1, 0.95, 0.85]} intensity={0.9} position={[8, 14, 6]} direction={[-8, -14, -6]} castShadow shadow={{ mapSize: 2048, normalBias: 0.02, cascades: 3, distance: 40 }} />
|
|
223
|
+
<Mesh geometry={plane({ width: FLOOR * 2 + 2, height: FLOOR * 2 + 2 })} material={ground} rotation={[-Math.PI / 2, 0, 0]} layers={1 | COLLIDER} />
|
|
224
|
+
{walls.map(w => (
|
|
225
|
+
<Mesh geometry={box({ width: w.size[0], height: w.size[1], depth: w.size[2] })} material={brick} position={w.position} layers={1 | COLLIDER} castShadow />
|
|
226
|
+
))}
|
|
227
|
+
<Mesh
|
|
228
|
+
geometry={box({ width: PLATFORM_HALF * 2, height: PLATFORM_TOP, depth: PLATFORM_HALF * 2 })}
|
|
229
|
+
material={stone}
|
|
230
|
+
position={[0, PLATFORM_TOP / 2, -PLATFORM_HALF - RAMP_RUN - PLATFORM_HALF]}
|
|
231
|
+
layers={1 | COLLIDER}
|
|
232
|
+
castShadow
|
|
233
|
+
/>
|
|
234
|
+
<Mesh geometry={box({ width: 2.5, height: RAMP_THICKNESS, depth: rampLength })} material={stone} position={rampCenter} rotation={[slope, 0, 0]} layers={1 | COLLIDER} castShadow />
|
|
235
|
+
{pillars.map(p => (
|
|
236
|
+
<Mesh geometry={cylinder({ radiusTop: 0.35, radiusBottom: 0.4, height: WALL_HEIGHT })} material={stone} position={p} layers={1 | COLLIDER} castShadow />
|
|
237
|
+
))}
|
|
238
|
+
{gems.map((g, i) => (
|
|
239
|
+
<Mesh ref={m => (pickups[i] = m)} geometry={sphere({ radius: 0.25 })} material={near().has(i) ? bright : dim} position={g} castShadow />
|
|
240
|
+
))}
|
|
241
|
+
</Scene>
|
|
242
|
+
<text position="absolute" left={12} top={12} color="#ffffff" fontSize={14}>
|
|
243
|
+
{hud()}
|
|
244
|
+
</text>
|
|
245
|
+
</view>
|
|
246
|
+
</window>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
render(() => <App />)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// A first-person walk: `<FirstPersonCamera>` driving the scene camera from
|
|
2
|
+
// WASD/arrows and the gamepad sticks, mouse look under pointer lock and
|
|
3
|
+
// drag-to-look without it. Click the scene to lock the pointer (the scene
|
|
4
|
+
// leaf takes focus on the same click, which routes the keys to the
|
|
5
|
+
// control), Escape releases it; on a touch screen a drag looks around and
|
|
6
|
+
// the left stick of a pad walks. Movement runs a frame loop only while a
|
|
7
|
+
// key is held or a stick deflected - a still scene renders nothing new.
|
|
8
|
+
// `clampPosition` keeps the walker inside the courtyard walls, the whole
|
|
9
|
+
// of the collision an app gets from a camera control. The `pose` debug
|
|
10
|
+
// command reads and sets the pose for headless checks.
|
|
11
|
+
import { createSignal, lockPointer, onFrame, pct, pointerLocked, render } from "@solidrt/core"
|
|
12
|
+
import type { PointerEvent } from "@solidrt/core"
|
|
13
|
+
import { box, cylinder, DirectionalLight, FirstPersonCamera, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene } from "@solidrt/3d"
|
|
14
|
+
import type { FirstPersonCameraHandle, Vec3 } from "@solidrt/3d"
|
|
15
|
+
import { registerDebug } from "srt:dev"
|
|
16
|
+
|
|
17
|
+
// Half extent of the walkable courtyard in world units; the walls stand
|
|
18
|
+
// just outside it.
|
|
19
|
+
const COURT = 9
|
|
20
|
+
// How far inside the walls the eye may go.
|
|
21
|
+
const WALL_GAP = 0.6
|
|
22
|
+
// Standing eye height.
|
|
23
|
+
const EYE = 1.6
|
|
24
|
+
// Pillar ring radius and count.
|
|
25
|
+
const RING = 5
|
|
26
|
+
const PILLARS = 8
|
|
27
|
+
// The sun's shadow is cascaded: a first-person eye sits 1.6 units over
|
|
28
|
+
// the floor, where one box map over the whole courtyard (32 units / 2048
|
|
29
|
+
// texels = 1.6 cm) shows every texel as a 10-pixel stair. Three cascades
|
|
30
|
+
// fitted to slices of the view give the near slice millimetre texels;
|
|
31
|
+
// the distance caps the far one at the courtyard's diagonal.
|
|
32
|
+
const SHADOW_CASCADES = 3
|
|
33
|
+
const SHADOW_DISTANCE = 30
|
|
34
|
+
// PCF radius in map texels: the 3x3 hardware-tap kernel that hides the
|
|
35
|
+
// remaining stair of the near cascade's texels.
|
|
36
|
+
const SHADOW_RADIUS = 2
|
|
37
|
+
// HUD refresh interval in ms: the pose readout is slow UI state, not a
|
|
38
|
+
// per-frame binding.
|
|
39
|
+
const HUD_MS = 100
|
|
40
|
+
|
|
41
|
+
let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
|
|
42
|
+
let inside = (p: Vec3): Vec3 => [clampNum(p[0], -COURT + WALL_GAP, COURT - WALL_GAP), EYE, clampNum(p[2], -COURT + WALL_GAP, COURT - WALL_GAP)]
|
|
43
|
+
|
|
44
|
+
function App() {
|
|
45
|
+
let camera!: FirstPersonCameraHandle
|
|
46
|
+
let [hud, setHud] = createSignal("")
|
|
47
|
+
let lastHud = 0
|
|
48
|
+
onFrame(tick => {
|
|
49
|
+
if (tick - lastHud < HUD_MS) return
|
|
50
|
+
lastHud = tick
|
|
51
|
+
let p = camera.pose()
|
|
52
|
+
setHud(
|
|
53
|
+
`${pointerLocked() ? "locked - Escape releases" : "click to lock"} x ${p.position[0].toFixed(1)} z ${p.position[2].toFixed(1)} yaw ${p.yaw.toFixed(2)} pitch ${p.pitch.toFixed(2)}`,
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
registerDebug("pose", (args?: Record<string, unknown>) => {
|
|
57
|
+
if (args) camera.set(args as Parameters<typeof camera.set>[0])
|
|
58
|
+
return camera.pose()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
let ground = lit({ color: [0.5, 0.52, 0.48] })
|
|
62
|
+
let stone = lit({ color: [0.75, 0.72, 0.66] })
|
|
63
|
+
let brick = lit({ color: [0.6, 0.32, 0.26] })
|
|
64
|
+
let gold = lit({ color: [0.9, 0.75, 0.3], specular: 0.5, shininess: 40 })
|
|
65
|
+
|
|
66
|
+
let pillars = Array.from({ length: PILLARS }, (_, i) => {
|
|
67
|
+
let a = (i / PILLARS) * Math.PI * 2
|
|
68
|
+
return [Math.sin(a) * RING, Math.cos(a) * RING] as const
|
|
69
|
+
})
|
|
70
|
+
let walls = [
|
|
71
|
+
{ position: [0, 1.5, -COURT - 0.5] as Vec3, size: [COURT * 2 + 2, 3, 1] as Vec3 },
|
|
72
|
+
{ position: [0, 1.5, COURT + 0.5] as Vec3, size: [COURT * 2 + 2, 3, 1] as Vec3 },
|
|
73
|
+
{ position: [-COURT - 0.5, 1.5, 0] as Vec3, size: [1, 3, COURT * 2] as Vec3 },
|
|
74
|
+
{ position: [COURT + 0.5, 1.5, 0] as Vec3, size: [1, 3, COURT * 2] as Vec3 },
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<window
|
|
79
|
+
onKeyDown={e => {
|
|
80
|
+
if (e.key === "Escape") lockPointer(false)
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
<view
|
|
84
|
+
width={pct(100)}
|
|
85
|
+
height={pct(100)}
|
|
86
|
+
onPointerDown={(e: PointerEvent) => {
|
|
87
|
+
if (e.pointerType === "mouse") lockPointer(true)
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<Scene clearColor={[0.6, 0.72, 0.88, 1]} samples={4} label="first-person">
|
|
91
|
+
<PerspectiveCamera fov={70} near={0.1} far={60} />
|
|
92
|
+
<FirstPersonCamera ref={c => (camera = c)} position={[0, EYE, 7]} clampPosition={inside} />
|
|
93
|
+
<HemisphereLight sky={[0.5, 0.56, 0.68]} ground={[0.25, 0.22, 0.18]} />
|
|
94
|
+
<DirectionalLight
|
|
95
|
+
color={[1, 0.95, 0.85]}
|
|
96
|
+
intensity={0.9}
|
|
97
|
+
position={[6, 10, 4]}
|
|
98
|
+
direction={[-6, -10, -4]}
|
|
99
|
+
castShadow
|
|
100
|
+
shadow={{ mapSize: 2048, normalBias: 0.02, radius: SHADOW_RADIUS, cascades: SHADOW_CASCADES, distance: SHADOW_DISTANCE }}
|
|
101
|
+
/>
|
|
102
|
+
<Mesh geometry={plane({ width: COURT * 2 + 2, height: COURT * 2 + 2 })} material={ground} rotation={[-Math.PI / 2, 0, 0]} />
|
|
103
|
+
{walls.map(w => (
|
|
104
|
+
<Mesh geometry={box({ width: w.size[0], height: w.size[1], depth: w.size[2] })} material={brick} position={w.position} castShadow />
|
|
105
|
+
))}
|
|
106
|
+
{pillars.map(([x, z]) => (
|
|
107
|
+
<Mesh geometry={cylinder({ radiusTop: 0.3, radiusBottom: 0.35, height: 3 })} material={stone} position={[x, 1.5, z]} castShadow />
|
|
108
|
+
))}
|
|
109
|
+
<Mesh geometry={box({ width: 1.2, height: 1.2, depth: 1.2 })} material={gold} position={[0, 0.6, 0]} castShadow />
|
|
110
|
+
</Scene>
|
|
111
|
+
<text position="absolute" left={12} top={12} color="#ffffff" fontSize={14}>
|
|
112
|
+
{hud()}
|
|
113
|
+
</text>
|
|
114
|
+
</view>
|
|
115
|
+
</window>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
render(() => <App />)
|