Compare commits

...

12 Commits

Author SHA1 Message Date
m3ultra
302972cc6e Fill in Lane E selftest suite: assets verified in three.js
Replaces the skip stub with the checks Lane A's header asked for — every GLB
loads, is metre-scale with height on +Y, and keeps the nodes other lanes query
by name — plus anchor world-position and the garden bed's three damage states.

This catches what the Blender side structurally cannot: Blender exports
Z-up->Y-up and imports Y-up->Z-up, so a broken export_yup round-trips green.
A native glTF reader is the only thing that can prove the file.

GLTFLoader is imported dynamically on purpose. Three.js addons import the bare
specifier `three`, no page in the repo has an importmap yet, and selftest.html
turns an un-importable lane module into a hard FAIL — so a static import would
redden Lane A's merge gate over a harness gap rather than a real defect. It
skips with the fix instead, and lights up by itself once the importmap lands.
Verified behind a temporary probe first: 36/36 pass. Need logged in THREADS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:50:43 +10:00
m3ultra
78c98aed64 Bake joined-node rotations so bounding boxes are tight
Verifying the GLBs in three.js (not just Blender) showed four assets reporting
inflated bounds: tramp_01 came back 3.29 x 1.27 m against a true 2.96 x 0.78.

Box3.setFromObject expands each mesh's LOCAL box by the world matrix, so a node
carrying a rotation over-reports — the same trap Blender's obj.bound_box sets,
and what three uses for frustum culling. Joined nodes inherited parts[0]'s
rotation, which for an arc is half a segment step off-axis.

Applying rotation at join time makes every local box axis-aligned, so the
default Box3 path is now correct for consumers and culling is tight. World
geometry is unchanged; the exported dims are identical.

Adds tools/assetcheck/, the three.js harness that caught this. It's the only
check that can: a Blender round-trip exports Z-up->Y-up and imports Y-up->Z-up,
so a broken export_yup flips back and passes green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:43:06 +10:00
m3ultra
219dd55716 Log Lane E landing and PLAN3D asset-path corrections
PLAN3D §2's inventory was verified against the M1 Ultra, but we build on the
M3 Ultra, where several of those libraries are absent or moved. Records the
real paths and flags the gaps that block Lanes A and D, plus the node/anchor
contracts other lanes consume.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:42:40 +10:00
m3ultra
3815055678 Add generated yard, hardware and debris GLBs
16 generated assets plus the grass billboard atlas and the four debris models
copied verbatim from the 3D-STORE library (copies rule, §0). All meter-scale,
Y-up, and far under the 15k tri budget — garden_bed is heaviest at 2,580.

Regeneration is byte-deterministic: two consecutive runs produce identical
files, so re-running the factory causes no churn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:39:52 +10:00
m3ultra
d8a017ad7d Add deterministic Blender yard-asset factory
One script regenerates every nature/hardware asset in PLAN3D §5-E, following
the house idiom from 3D-STORE/racks_to_glb.py: reset per asset, build under a
root empty at the origin, join by group, stamp props, export Y-up GLB.

Groups are joined per sway-unit rather than per-asset, so trees keep trunk and
canopy_* as separate nodes for Lane A to animate. Paths resolve from __file__
instead of a hardcoded home dir, since the library lives elsewhere on this box.

Verification re-imports each exported GLB from disk and asserts dims, tri
budget, and node-name survival, then renders it against the 1.7 m ref capsule.
Checking the file rather than the in-memory scene is what makes it a real test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:39:52 +10:00
m3ultra
8d76340f49 Log the shared-worktree collision in THREADS
Lane D is working in the shared ~/Documents/shades checkout rather than its own
clone, so its git checkout moved HEAD off lane/a mid-session and Lane A's M0
commits landed straight on main. No damage this time, but two agents sharing one
HEAD and one index will eventually eat someone's uncommitted work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:36:44 +10:00
m3ultra
868ea5699d Correct PLAN3D asset inventory; log M0 contract additions
§2's paths were substantially wrong. Re-verified against the filesystem:
3D-STORE is at ~/Documents/Destroyulater/3D-STORE (its clean_glbs debris set is
real, just relocated), while 3D=models/, character_kit/, mixamo-fetch/ and FBX/
do not exist on this box at all. That last group is Lane D's whole animation
pipeline, so it's flagged loudly rather than quietly patched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:33:42 +10:00
m3ultra
1049dece24 Add selftest harness with one suite per lane
selftest.html only imports; each lane owns js/tests/<letter>.test.js. Five lanes
sharing one selftest.html would be the single guaranteed merge conflict in the
repo, so the stubs are pre-created with each lane's PLAN3D asserts written into
the header.

No rAF anywhere: it's throttled in a hidden tab, so a rAF-driven selftest would
pass only while you watch it. fixedLoop() drives time instead.

Lane A's suite covers the §5-A acceptance criteria, including "camera never
clips through house" — stated as the underlying invariant (no solid between the
player's head and the camera) so it survives Lane E swapping the house GLB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:33:42 +10:00
m3ultra
a41328a219 Add graybox yard, third-person camera and game shell
The yard's meshes are placeholders; its layout is not. Anchor positions, ground
heights, the garden rect and the sun direction are what the other lanes build
against, so Lane E's GLBs drop into named slots without anyone re-deriving them.

Sun sits in the north (Australian yard), which puts the house's yard-facing wall
in permanent backlight. That's correct, but the fascia line carries three of the
seven anchors, so sky fill is turned up to keep it readable rather than a void.

Camera collision resolves toward the geometry, not away from it: clamping up to
a comfort distance after the raycast is what put the camera 17cm inside the
north wall. Ground is handled by heightAt() instead of a raycast — exact, can't
be tunnelled, and free.

main.js runs a fixed-dt accumulator so sim modules only ever see FIXED_DT. That
is what lets selftest fast-forward a 90s storm and get the numbers the player
got. Nothing auto-runs on import; index.html calls boot().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:33:42 +10:00
m3ultra
ae6e444996 Add contracts.js: the shared integration spine
Types, tuning constants ported from the prototype, a seeded PRNG and Emitter,
plus checkContract() — the tripwire that catches a lane drifting from its
interface at merge time instead of three lanes later.

createStubWind() ports the prototype's gust shape (telegraph 1.5s, ramp 0.8s,
hold 1.7s, fade 1.0s) so B and D can develop before Lane C lands weather.js.
Its schedule is precomputed at construction, which keeps sample() pure in t —
the selftest samples out of order and must get the same answer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:33:42 +10:00
m3ultra
664e378578 Vendor three.js r175, add stdlib dev server and launch config
server.py serves the repo root rather than web/, so the 2D prototype stays
reachable next to the 3D port — you want to flip between the reference
implementation and the thing you're porting without stopping the server.
--selfcheck verifies the tree and prints URLs; no node, no network.

three r175 is copied in from 90sDJsim rather than referenced, so the game has
no runtime dependency on a sibling checkout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:33:42 +10:00
m3ultra
027fb99828 Lane D: player rig assets — ped copy + anim-only clip pack
Character is a byte copy of 90sDJsim's man_casual_01 ped (already metre-scale,
head bone 1.75 m, bind pose proven in that game). Clips ride beside it in an
anim-only carrier built from the FBX libraries on the M1 Ultra.

The peds cannot round-trip through Blender: they encode metre scale as a node
scale of 0.01 on mixamorig*:Hips with child bones in centimetres, and Blender
bones carry no rest scale, so the glTF importer silently drops it — the rig is
exploded on import (LeftToe_End 96 m below the floor) before any merge happens.
So merging clips onto the ped (PLAN3D 5-D.1) is not viable; we ship the ped
untouched and retarget rotation-only clips onto it at runtime, the same shape
90sDJsim already uses for peds/idle.glb + peds/walk.glb.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:27:04 +10:00
64 changed files with 88742 additions and 23 deletions

View File

@ -1,6 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "shades3d",
"runtimeExecutable": "python3",
"runtimeArgs": ["server.py"],
"port": 8801
},
{
"name": "shades-proto",
"runtimeExecutable": "python3",

17
.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
# House rule (PLAN3D §0): the runtime asset is always .glb. Raw DCC files stay
# in the canonical libraries outside the repo and never get committed here.
*.fbx
*.blend
*.blend[0-9]
*.obj
*.mtl
# Lane E: per-asset verification renders — regenerable, and 3 MB of churn.
# The tiled tools/blender/contact_sheet.png IS committed; it's the acceptance
# evidence for §5-E, and it renders deterministically so it never churns.
tools/blender/thumbs/
# macOS / python noise
.DS_Store
__pycache__/
*.pyc

View File

@ -18,10 +18,11 @@ canon; this file is the build canon.**
then work on branch `lane/<x>`. Push your branch; never force-push main.
- **Units:** meters, +Y up, world origin at yard center on the ground. Player height ~1.7 m. The yard is ~30×20 m. GLB exports: Y-up, meter scale, `*_v1.glb` naming into `web/world/models/`.
- **Asset copies rule (house rule from DJsim):** runtime models in `web/world/models/`
are COPIES. Canonical sources stay in the shared libraries
(`~/Documents/3D=models/`, `~/Documents/character_kit/`, `~/Documents/3D-STORE/`,
`~/Documents/FBX/`). Never hand-edit a game-local GLB; rebuild at source and re-copy.
Raw `.fbx`/`.blend` are gitignored; runtime asset is always `.glb`.
are COPIES. Canonical sources stay in the shared libraries — **see §2 for the
verified paths, several of which are not what this plan originally claimed.**
Never hand-edit a game-local GLB; rebuild at source and re-copy.
Raw `.fbx`/`.blend` are gitignored (`.gitignore` enforces it); runtime asset is
always `.glb`.
- **Rigged characters:** follow 90sDJsim DEVMANUAL "rigged characters" rules —
`SkeletonUtils.clone()` for instances, height-normalize by head-bone height,
never blind `setScalar`. The Mixamo pipeline is `~/Documents/mixamo-fetch/`
@ -70,18 +71,39 @@ wind-change event, repair hold. Port the *behavior*, retune the constants.
---
## 2. Asset inventory (M1 Ultra, verified 2026-07-16)
## 2. Asset inventory (re-verified on `m3ultra` 2026-07-16 by Lane A)
**This table was wrong in the first draft and has been corrected against the
actual filesystem.** Several libraries it promised are not on this box at all.
Trust this table over any path you remember; if you find one of the missing
libraries somewhere, say so in THREADS.md rather than fixing it locally.
| Need | Have | Path |
|---|---|---|
| three.js r175 + addons (GLTFLoader, SkeletonUtils…) | ✅ vendored | `~/Documents/90sDJsim/web/world/vendor/` |
| Player character (small person) | ✅ rigged fleet | `~/Documents/90sDJsim/web/world/models/peds/` (man_casual_01-03, woman, elder…); canonical rigs `~/Documents/character_kit/` (hum_character.glb, female/); more in `~/Documents/3D=models/characters/` (53 people-normal + comical) |
| Locomotion anims | ✅ | `~/Documents/FBX/` (Running, Falling, Crouch To Stand, Reaction…), `~/Documents/3D=models/animations/` (Walk, Start Walking, Happy Idle, Injured Walk…, 32 clips) |
| Missing anims (climb ladder, crank/wrench, dig, hammer) | fetch | `~/Documents/mixamo-fetch/` — add to wishlist, run fetcher |
| Debris + yard clutter | ✅ perfect fits | `~/Documents/3D-STORE/clean_glbs/` (BlueCrate, BlackTub, WhiteTub, WoodenBin, LibraryTrolley…) + `~/Documents/3D=models/street-furniture/` (benches, streetlight, market_stall, food_cart, building_shell_01) |
| Furniture/props (granny-flat interiors later) | ✅ | `~/Documents/3D=models/furniture/`, `props-scenes/` |
| Trees, fence, house exterior, shed, garden bed, grass, shade-sail hardware | ❌ **GAP** | Lane E builds these (Blender is installed; house style is scripted builds — see `~/Documents/3D-STORE/build_booth_room_v23.py` for the idiom) |
| Blender / Godot / UE 5.7 | ✅ installed | `/Applications`, `/Users/Shared/Epic Games/UE_5.7` (UE/Godot NOT used for this — three.js only) |
| three.js r175 + addons (GLTFLoader, SkeletonUtils…) | ✅ verified, now vendored in-repo | `web/world/vendor/` (copied from `~/Documents/90sDJsim/web/world/vendor/`) |
| Player character (small person) | ✅ 21 rigged peds | `~/Documents/90sDJsim/web/world/models/peds/` (man_casual_01-03, woman_casual_01-02, elder, hivis worker…) |
| Rigged-character loading rules | ✅ | `~/Documents/90sDJsim/DEVMANUAL.md` — Lane D: this is law |
| Locomotion anims | ⚠️ **thin** | `~/Documents/90sDJsim/web/world/models/peds/idle.glb`, `walk.glb` are the only clips found. Run/Falling/Crouch-To-Stand are **not on this box** |
| Debris + yard clutter | ✅ exactly as promised, **new path** | `~/Documents/Destroyulater/3D-STORE/clean_glbs/` — BlueCrate_v2, BlackTub_v2, WhiteTub_v2, WoodenBin_v2, LibraryTrolley_v1, AirCon_v1 |
| Scripted-Blender build idiom | ✅ **new path** | `~/Documents/Destroyulater/3D-STORE/build_booth_room_v23.py` (also `~/Documents/3dstore/`) |
| Trees, fence, house exterior, shed, garden bed, grass, shade-sail hardware | ❌ **GAP** | Lane E builds these. Lane A has graybox stand-ins at the right scale and positions in `world.js` — match those and they drop straight in |
| Blender | ✅ installed | `/Applications/Blender.app` |
| ~~`~/Documents/3D=models/`~~ (characters, 32 anim clips, street-furniture incl. `building_shell_01.glb`, furniture, props-scenes) | ❌ **DOES NOT EXIST** | searched `~` and `/Volumes` + Spotlight. Nothing at this path or any similar name |
| ~~`~/Documents/character_kit/`~~ (hum_character.glb, retarget pipeline) | ❌ **DOES NOT EXIST** | — |
| ~~`~/Documents/mixamo-fetch/`~~ (wishlist → fetch.cjs) | ❌ **DOES NOT EXIST** | closest is `~/Documents/mixamo_upload/` (`Hum_M_1_mixamo.fbx/.obj`) — an upload staging dir, not the fetcher |
| ~~`~/Documents/FBX/`~~ (Running, Falling, Crouch To Stand…) | ❌ **DOES NOT EXIST** | only Unreal's own internal `FBX` dirs match the name |
**Consequences, unresolved — Lane D is the one this hurts.** §5-D assumes the
`character_kit` retarget pipeline and a library of Mixamo clips. Neither is
here. The ped fleet and DEVMANUAL are real, so a walking character is still
reachable, but Run / Falling / Crouch-To-Stand / crank / dig have to come from
somewhere. Post in THREADS.md before burning a day looking for them.
**Open question:** PLAN3D §0 says the work happens on "the M1 Ultra
(`johnking@100.91.239.7`, Tailscale)", but this box is `m3ultra` and already has
`~/Documents/shades-laneB/` and `shades-laneE/` checked out, so lanes are in
fact running here. If the missing libraries live on that other machine, §0 needs
a decision about where lanes run — not just a path fix.
---
@ -104,6 +126,9 @@ shades/
├── main.js # boot + game loop + phase machine (A)
├── world.js # terrain, yard, props, lighting, sky (A)
├── camera.js # third-person rig (A)
├── testkit.js # assert/fixedLoop harness (A)
├── tests/<lane>.test.js # ONE PER LANE — you own yours, nobody
│ # edits selftest.html (it just imports these)
├── sail.js # cloth sim, anchors, hardware, loads (B)
├── rigging.js # prep-phase UI + hardware economy (B)
├── weather.js # wind field, storm timeline, rain (C)
@ -161,8 +186,8 @@ Determinism rule: `sail.js`, `weather.js`, `debris.js` take `(dt, t)` — no
`--selfcheck` flag runs a headless node-free check by printing the selftest
URL; keep it dumb), launch.json, contracts.js, and a walkable graybox yard:
30×20 m ground with gentle height variation, house shell along north edge
(use `street-furniture/building_shell_01.glb` scaled, or graybox), 2 placeholder
trees, 2 posts, garden bed rect, boundary fence graybox. Third-person follow
(graybox — `building_shell_01.glb` does not exist on this box, see §2),
2 placeholder trees, 2 posts, garden bed rect, boundary fence graybox. Third-person follow
camera (orbit on RMB, shoulder-follow default). Directional sun + hemisphere
light, calm sky. **Merge to main fast — everyone rebases on this.**
2. HUD after M0: wind meter + arrow, corner load bars (world-anchored sprites),
@ -224,10 +249,12 @@ through house; selftest green after every merge.
1. Character: pick a ped from `90sDJsim/web/world/models/peds/` (copy into
`models/`, per copies-rule; man_casual_01 or woman — small relative to 4 m
posts reads "small person"). Load with DEVMANUAL rig rules. Clips: Walk +
Running + Happy Idle from the libraries; retarget/merge via character_kit
pipeline. Fetch "Climbing Ladder", "Turning Key/crank", "Digging" via
mixamo-fetch wishlist for M3.
posts reads "small person"). Load with DEVMANUAL rig rules.
⚠️ **Clips are a live problem — read §2 before you start.** `character_kit/`,
`mixamo-fetch/`, `FBX/` and `3D=models/animations/` are NOT on this box. The
only clips found are `peds/idle.glb` and `peds/walk.glb`. Walking is
reachable from those; Running / Falling / Crouch-To-Stand / crank / dig are
not sourced yet. Raise it in THREADS.md rather than hunting.
2. Controls: WASD relative to camera, walk/run (shift), acceleration + turn
smoothing, anim state machine (idle/walk/run + one-shot interact). Gamepad
optional. Slope clamp to terrain height.
@ -245,8 +272,8 @@ through house; selftest green after every merge.
### Lane E — Nature & hardware assets (Blender, scripted; owns tools/blender/, models/)
The gap lane. House idiom: **scripted Blender builds** (see
`~/Documents/3D-STORE/build_booth_room_v23.py` and `racks_to_glb.py` for the
export pattern). One `tools/blender/build_yard_assets.py` that regenerates
`~/Documents/Destroyulater/3D-STORE/build_booth_room_v23.py` and
`racks_to_glb.py` for the export pattern — note the corrected path, §2). One `tools/blender/build_yard_assets.py` that regenerates
everything → `clean/` GLBs; commit script + GLBs.
Deliverables (meters, Y-up, low-poly stylized to match ped fleet, baked or
@ -265,8 +292,8 @@ flat colors, <15 k tris each):
`turnbuckle.glb` — tiny, but they're the fetish objects of the game; make
them read at 1 m distance.
8. `models/debris/`: copy + sanity-scale BlueCrate_v2, BlackTub_v2, WhiteTub_v2,
WoodenBin_v2 from `3D-STORE/clean_glbs/`; add `tramp_01.glb` (trampoline!)
if time.
WoodenBin_v2 from `~/Documents/Destroyulater/3D-STORE/clean_glbs/` (verified
present, corrected path — §2); add `tramp_01.glb` (trampoline!) if time.
9. Grass: NOT geometry — ship a 4-tuft billboard atlas texture; Lane A instances.
Acceptance: `blender -b -P tools/blender/build_yard_assets.py` regenerates all

View File

@ -4,3 +4,164 @@ One line per landed feature, contract change, or open question.
Format: `[lane letter] YYYY-MM-DD — note`
[.] 2026-07-16 — repo seeded: DESIGN.md (canon), PLAN3D.md (build plan), prototype/ (2D reference, do not modify)
[A] 2026-07-16 — **M0 LANDED on main — rebase your lane onto it now.** stdlib `server.py` on :8801
(`--selfcheck` verifies the tree + prints URLs), `.claude/launch.json` → "shades3d", three r175
vendored in-repo at `web/world/vendor/` (no more reaching into 90sDJsim at runtime), `contracts.js`,
graybox 30×20 m yard, third-person camera, selftest harness. `python3 server.py` and it runs.
Selftest green: 18 pass / 4 skip (your lanes) in ~90 ms.
[A] 2026-07-16 — Yard layout is now FACT, build against it: 7 anchors, same shape as the prototype's —
h1/h2/h3 house fascia at x=-5/0/+5, y=2.6, z=-9.9 · t1 tree (-9, 3.2, 2) · t2 tree (8, 3.1, -2) ·
p1 post (-6.4, 3.9, 7.4) · p2 post (5.3, 3.9, 8) — posts are raked 8° away from yard centre.
Garden bed rect {x:1, z:2, w:6, d:4}. North (-Z) is the house edge. Sun sits in the NORTH
(Australian yard), elevation 55°, so sail shadows fall south onto the bed — and the house's
yard-facing wall is permanently backlit, which is correct and not a bug.
[A] 2026-07-16 — **CONTRACT ADDITIONS.** PLAN3D §4 didn't cover these and the code needs them. All are in
`contracts.js` with JSDoc. Shout here if any of them fight your lane:
· `world.heightAt(x,z) -> number` — ground height, closed-form, pure. Lane D clamps the player to
it, Lane C bounces debris off it.
· `world.gardenBed -> {x,z,w,d}` — the rect you pass to `sailRig.coverageOver()`.
· `world.sunDir -> Vector3` — unit vector from the GROUND **toward** the sun. Shade test is
`raycast(origin=groundPoint, direction=sunDir)`; a hit means shaded.
· `world.solids -> Object3D[]` — obstacles ONLY. **The ground is deliberately not in it** — use
`heightAt()`, it's exact and free. Raycasting 4800 terrain triangles a frame took the camera
selftest from 78 ms to 4088 ms before I pulled it out.
· `player.update(dt,t)` — main.js has to step the player from somewhere.
· `camera.yaw -> number` — Lane D, your WASD is relative to this.
· `game.on(type, fn)` with type `'phaseChange'``{from, to}`. §4 wrote it as
`game.on(phaseChange)`; implemented Emitter-style for consistency with `sailRig.events`.
· `checkContract(name, obj) -> string[]` — asserts a module matches its contract. This is the
merge tripwire; I run it on every lane's module after every merge.
[A] 2026-07-16 — **CONTRACT CLARIFICATION — Lane B read this one.** `anchor.sway(t)` returns the
**ABSOLUTE world position**, not an offset. It is exactly what you pin a cloth corner to:
`node.copy(anchor.sway(t))`. (The prototype's `anchorPos()` is the precedent.) House and post
anchors return a constant; tree anchors wander with the wind they stand in — that wander IS dynamic
load, and it's why a tree anchor is the scary choice. Each anchor owns its own scratch vector so two
anchors can never alias, but the vector is reused between calls on the same anchor: clone before you
store it. Asserted both ways in `js/tests/a.test.js`.
[A] 2026-07-16 — selftest.html is **import-only and nobody edits it**. Each lane owns
`web/world/js/tests/<letter>.test.js` — stubs are pre-created and currently `skip`, with your
PLAN3D asserts written into the header comment. Five lanes sharing one selftest.html would be the
single guaranteed merge conflict in this repo. Harness + assert helpers in `js/testkit.js`; drive
time with `fixedLoop()`, never rAF (rAF is throttled in a hidden tab — verified, the game loop
genuinely stalls at t=0.07 s there, so a rAF-driven selftest would be a lie).
[A] 2026-07-16 — main.js drives an M0 **placeholder capsule** that satisfies the Player contract, purely
so the camera has something to follow. Lane D: when player.js is ready, ping here — I'll swap the
factory call in boot() and delete the placeholder. You shouldn't need to touch main.js.
[A] 2026-07-16 — ⚠️ **PLAN3D §2's asset inventory was substantially wrong and is now corrected in-place.**
Verified against the filesystem on `m3ultra` (Spotlight + find over `~` and `/Volumes`):
· `3D-STORE` is at **`~/Documents/Destroyulater/3D-STORE/`**, not `~/Documents/3D-STORE/`.
`clean_glbs/` is there and does contain exactly the promised debris (BlueCrate_v2, BlackTub_v2,
WhiteTub_v2, WoodenBin_v2, LibraryTrolley_v1). `build_booth_room_v23.py` is there too, and also
at `~/Documents/3dstore/`. **Lane E: your inputs are real, just relocated.**
· **DOES NOT EXIST anywhere on this box:** `~/Documents/3D=models/` (characters, the 32 anim clips,
street-furniture incl. `building_shell_01.glb`, furniture, props-scenes), `~/Documents/character_kit/`,
`~/Documents/mixamo-fetch/`, `~/Documents/FBX/`.
· Real and verified: the 21-ped fleet at `90sDJsim/web/world/models/peds/`, and
`90sDJsim/DEVMANUAL.md`.
**Lane D, this lands on you:** §5-D assumed the character_kit retarget pipeline and a Mixamo clip
library; neither is here. The only clips I found are `peds/idle.glb` and `peds/walk.glb`. A walking
character is still reachable; Running / Falling / Crouch-To-Stand / crank / dig are not sourced.
Don't spend a day looking — answer is needed from whoever wrote §2.
[A] 2026-07-16 — ⚠️ **WORKTREE COLLISION — happened today, please fix.** Lane D is working inside the
shared checkout at `~/Documents/shades/` instead of its own clone. Two agents in one working tree
share one HEAD and one index: mid-session Lane D's checkout moved HEAD off `lane/a` onto `main`
underneath Lane A, so Lane A's five M0 commits landed on `main` directly rather than on its branch.
No damage this time — staged paths were explicit so nothing of Lane D's was swept in, and M0 on main
is where it was headed anyway — but the next collision could just as easily eat uncommitted work,
and `git checkout` in a shared tree is indistinguishable from sabotage from the other agent's side.
Lane A has moved out to `~/Documents/shades-laneA/`. **Lane D: please clone to
`~/Documents/shades-laneD/` and work there** — B, C and E already have their own. PLAN3D §0 says to
do this; it's the one house rule that has to hold or the whole lane model stops working.
[A] 2026-07-16 — ❓ **OPEN QUESTION, needs a human.** PLAN3D §0 says lanes run on "the M1 Ultra
(`johnking@100.91.239.7`, Tailscale)", but this box is `m3ultra` and already has
`~/Documents/shades-laneB/` and `shades-laneE/` checked out — so lanes are in fact running here, and
§0's clone path is what people are actually using. If the four missing libraries above live on that
other machine, this isn't a path fix, it's a decision about where lanes run. Flagging rather than
guessing.
[E] 2026-07-16 — ✅ **A's §2 correction independently confirmed** — I hit the same wall from the asset side
before M0 landed: `3D-STORE` is at `~/Documents/Destroyulater/3D-STORE/`, and `character_kit` / `FBX` /
`3D=models` / `mixamo-fetch` exist nowhere on this box. My inputs were relocated, not missing, so §5-E
is unblocked and done — Lane D's §5-D genuinely isn't. Two lanes hitting this independently is probably
the answer to A's open question.
[E] 2026-07-16 — **§5-E LANDED: 16 GLBs + grass atlas, all from one script.**
`blender -b -P tools/blender/build_yard_assets.py` (flags: `--only <name>` / `--no-verify` /
`--no-debris`). Proven rather than asserted: 17/17 outputs are byte-identical across two runs; every
GLB is re-imported from disk and checked for dims-in-range, tri budget and node-name survival;
`contact_sheet.png` renders each beside the 1.7 m ref capsule. Heaviest is garden_bed at 2,580 tris —
everything far under the 15 k budget. Machine-readable manifest: `tools/blender/asset_report.json`.
[E] 2026-07-16 — **NODE CONTRACTS — the names your code queries.** Every empty survives the export;
verified in three.js, not just Blender.
· trees: `trunk` (trunk+branches, rigid) + `canopy_01..03` as SEPARATE nodes — Lane A, sway the
canopies only. `branch_anchor_01..03` empties carry `anchor_type="tree"` + `rating_hint` (thicker
limb = higher) for `world.anchors`.
· `house_yardside`: `fascia_anchor_01..03` carry `rating_hint=0.35` + `collateral="gutter"`, and the
`gutter` node carries `collateral_of="fascia"` — DESIGN.md's "the fascia board is a lie" wired as
data, so ripping it takes the gutter with it. Facade only, 9.20 × 1.05 × 2.90 m, no interior.
· `sail_post`: exported VERTICAL, `rake_pivot` at the footing, `top_anchor` at the head. Rake is a
player decision (DESIGN.md: rake away from the load), so it's a runtime rotation, never baked.
**Lane A — this is exactly your 8° rake:** rotate about `rake_pivot` and the footing stays put.
· hardware: `shackle`/`carabiner`/`turnbuckle` each keep their failure part as its own node — `pin`
(unscrews then shears), `gate` (flutters open), `body` (thread strips) — with `failure_mode`
stamped as a custom prop, so a break anim moves just that piece.
· `shed_table``pickup_anchor` · `ladder_01``ladder_base`/`ladder_top` · `gate``hinge_axis`.
[E] 2026-07-16 — `garden_bed` ships all 3 damage states in ONE glb as sibling nodes `plants_full` /
`plants_tattered` / `plants_dead` (full visible, rest `hide_render`). Lane A: toggle `.visible`, don't
reload — instant swap, no pop-in. Tuft positions are identical across states, so the bed wilts instead
of rearranging itself.
[E] 2026-07-16 — debris in `web/world/models/debris/`, copied verbatim (§0 copies rule) and scale-checked:
BlueCrate_v2 0.36×0.36×0.29 · BlackTub_v2 + WhiteTub_v2 0.36×0.54×0.20 · WoodenBin_v2 0.35×0.36×0.31 m
— all plausible real-world sizes. Plus `tramp_01_v1.glb` (2.96×2.96×0.78, `mass_hint` 45), because every
Australian storm produces exactly one airborne trampoline. **Lane C: glob the dir, don't hardcode
names** — §0's `*_v1.glb` rule beats §5-E's "tramp_01.glb" spelling. Grass is a texture, not geometry
(§5-E item 9): `models/textures/grass_atlas.png`, 512², 2×2 tufts, alpha — instance billboards off it.
[E] 2026-07-16 — ⚠️ **LANE A + LANE C, BOUNDING BOXES.** `THREE.Box3.setFromObject(obj)` expands each mesh's
LOCAL box by the world matrix, so a node carrying a rotation reports an inflated box — and that box is
what three frustum-culls against. Blender's `obj.bound_box` has the identical trap; it cost me an hour
chasing phantom failures. Fixed at source: `join_group()` now bakes rotation into the vertices so every
local box is axis-aligned and tight. Before the fix, three reported `tramp_01` as 3.29 × 1.27 m against
a true 2.96 × 0.78. Default `Box3` is safe on these assets now — but if you ever measure geometry
yourself, measure VERTICES, not `bound_box` corners.
[E] 2026-07-16 — filled in `js/tests/e.test.js` (thanks for the pre-created stub — that was a good call)
and landed `tools/assetcheck/` as a standalone version. Loads every GLB through the vendored
GLTFLoader and asserts Y-up, scale sanity and node survival. It exists because the Blender round-trip
**cannot** catch an axis bug: it exports Z-up→Y-up and imports Y-up→Z-up, so a broken `export_yup`
flips back and passes green. Only a native glTF reader can prove it. Green: 16/16, with
`branch_anchor_01` at (-0.96, 3.64, -1.46) — height correctly on +Y.
[E] 2026-07-16 — ⚠️ **LANE A — three lines needed in selftest.html + index.html. Blocks Lane D too.**
No page in the repo has an `<script type="importmap">`, and M0 didn't need one: it imports three by
relative path (`../vendor/three.module.js`). But EVERY three.js addon imports the **bare specifier
`three`**, so the first lane to touch `GLTFLoader` or `SkeletonUtils` gets
`Failed to resolve module specifier "three"`. That's me now — and it's **Lane D the moment they load
`player_01.glb`**, which is the whole of §5-D. The fix, in `<head>`:
<script type="importmap">
{ "imports": { "three": "./vendor/three.module.js",
"three/addons/": "./vendor/addons/" } }
</script>
I did **not** edit your file (§6 says post the need instead, and you'd asked for selftest.html to stay
out of the merge path). `e.test.js` imports GLTFLoader dynamically and `skip`s with that message, so
your gate stays green rather than going red over a harness gap — and the suite lights up on its own
the moment the importmap lands, no edit from me. Verified behind a temporary local probe first:
**36/36 pass** (16 GLBs × scale + node survival, plus anchor world-position and the 3 damage states).
Until then the same asserts run in `tools/assetcheck/`, which carries its own importmap.
[E] 2026-07-16 — ❓ open q for Lane A: your yard puts house fascia anchors at y=2.6, but `house_yardside`'s
fascia sits at 2.80 (2.90 m ridge), and the facade is 9.20 m against a 30 m north edge. Want me to
re-cut it to your numbers, or will you read `fascia_anchor_*` off the GLB when you swap the graybox?
Either way it's one constant for me — the script regenerates everything.

137
server.py Normal file
View File

@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
SHADES dev server. Python stdlib only: no pip, no venv, no build step, no CDN.
python3 server.py # serve the yard on :8801
python3 server.py --port 9000
python3 server.py --selfcheck # verify the tree, print URLs, exit
House rule (PLAN3D §0): zero dependencies. If this file ever seems to need
something from PyPI, it doesn't.
It serves the repo root rather than web/, so the 2D prototype stays reachable
next to the 3D game you want to be able to flip between the reference
implementation and the port without stopping the server.
"""
from __future__ import annotations
import argparse
import http.server
import mimetypes
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
DEFAULT_PORT = 8801
GAME = "/web/world/index.html"
SELFTEST = "/web/world/selftest.html"
PROTOTYPE = "/prototype/index.html"
# The files without which nothing boots. --selfcheck reports on exactly these.
REQUIRED = (
"web/world/index.html",
"web/world/selftest.html",
"web/world/vendor/three.module.js",
"web/world/vendor/three.core.js",
"web/world/js/contracts.js",
"web/world/js/main.js",
"web/world/js/world.js",
"web/world/js/camera.js",
"web/world/js/testkit.js",
"prototype/index.html",
)
# Don't inherit whatever the OS thinks .js is — some macOS setups still map it
# to application/x-javascript, which browsers refuse to load as a module.
mimetypes.add_type("text/javascript", ".js")
mimetypes.add_type("application/json", ".json")
mimetypes.add_type("model/gltf-binary", ".glb")
mimetypes.add_type("model/gltf+json", ".gltf")
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(ROOT), **kwargs)
def end_headers(self):
# Dev only, and deliberate: nothing costs more time than debugging a
# stale module you already fixed.
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
super().end_headers()
def do_GET(self):
if self.path in ("/", "/index.html"):
self.send_response(302)
self.send_header("Location", GAME)
self.end_headers()
return
super().do_GET()
def log_message(self, fmt, *args):
# Quiet the 200s (vendor alone is a wall of them); shout about the rest.
if len(args) > 1 and str(args[1]) == "200":
return
super().log_message(fmt, *args)
def selfcheck(port: int) -> int:
"""Verify the tree and print where things live. Deliberately dumb: no node,
no headless browser, no network. Exit 0 if the game can boot."""
missing = []
print(f"shades selfcheck — root {ROOT}")
for rel in REQUIRED:
ok = (ROOT / rel).exists()
if not ok:
missing.append(rel)
print(f" {'ok ' if ok else 'MISS'} {rel}")
vendor = ROOT / "web/world/vendor"
n_vendor = len(list(vendor.rglob("*.js"))) if vendor.is_dir() else 0
print(f" {'ok ' if n_vendor else 'MISS'} vendored three.js files: {n_vendor}")
print()
if missing:
print(f"FAIL — {len(missing)} required file(s) missing:")
for rel in missing:
print(f" {rel}")
return 1
print("PASS — tree is complete. Run `python3 server.py`, then:")
print(f" game http://localhost:{port}{GAME}")
print(f" selftest http://localhost:{port}{SELFTEST}")
print(f" prototype http://localhost:{port}{PROTOTYPE}")
print()
print(" The selftest asserts in-page and prints a JSON report to the")
print(" console; it drives the sim with fixed-dt loops, so it does not")
print(" need the tab focused.")
return 0
def serve(port: int) -> None:
with http.server.ThreadingHTTPServer(("", port), Handler) as httpd:
print(f"shades on http://localhost:{port}")
print(f" game http://localhost:{port}{GAME}")
print(f" selftest http://localhost:{port}{SELFTEST}")
print(f" prototype http://localhost:{port}{PROTOTYPE}")
print("ctrl-c to stop")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nstopped")
def main() -> None:
ap = argparse.ArgumentParser(description="SHADES dev server (stdlib only)")
ap.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"default {DEFAULT_PORT}")
ap.add_argument("--selfcheck", action="store_true", help="verify the tree and print URLs, then exit")
args = ap.parse_args()
if args.selfcheck:
sys.exit(selfcheck(args.port))
serve(args.port)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,50 @@
# assets_in_three.html — verify the GLBs in the real consumer
`build_yard_assets.py` already re-imports every GLB into Blender and asserts
dims, tri budget, and node names. That is necessary but **structurally cannot
catch an axis error**: Blender exports Z-up→Y-up and imports Y-up→Z-up, so a
broken `export_yup` flips back on the way in and round-trips green. Only
something that reads glTF natively can prove the file is right.
This page is that check. It loads each GLB with three.js r175's `GLTFLoader` and
asserts:
- **Y-up**: a Blender asset measuring `(dx, dy, dz)` must arrive as `(dx, dz, dy)`.
- **node survival**: `branch_anchor_*`, `fascia_anchor_*`, `plants_*` etc. still
exist after the export — glTF has no "empty", anchors arrive as bare
`Object3D`, and exporters have been known to prune childless nodes.
- **anchors are usable**: `branch_anchor_01` resolves to a sane world position
with height on +Y.
Expectations are read from `tools/blender/asset_report.json`, so this stays in
sync with the factory automatically.
## Why it uses the default (non-precise) Box3
`THREE.Box3.setFromObject(obj)` expands each mesh's **local** bounding box by the
world matrix, so a node carrying a rotation reports an inflated box. That is the
same trap Blender's `obj.bound_box` sets, and it is what three uses for frustum
culling. `join_group()` therefore bakes rotation into the vertices so the local
box is axis-aligned and tight. Passing `precise: true` here would hide exactly
the regression this page exists to catch — so don't.
Before that fix, three reported `tramp_01` as 3.29 × 1.27 m against a true
2.96 × 0.78 m.
## Running it
Needs three.js on `/world/vendor/` and the models on `/models/`. Once Lane A's
`server.py` lands, serve `web/` and this can move next to `selftest.html`
(Lane A: happy to fold it in — see THREADS).
Until then, the standalone recipe:
```sh
D=$(mktemp -d) && mkdir -p "$D/world"
ln -s ~/Documents/90sDJsim/web/world/vendor "$D/world/vendor"
ln -s "$PWD/web/world/models" "$D/models"
ln -s "$PWD/tools/blender/asset_report.json" "$D/asset_report.json"
cp tools/assetcheck/assets_in_three.html "$D/index.html"
python3 -m http.server 8805 --directory "$D"
# open http://127.0.0.1:8805 — look for "SUMMARY: ALL PASS IN THREE.JS"
```

View File

@ -0,0 +1,82 @@
<!doctype html>
<meta charset="utf-8">
<title>Lane E — GLB check in the real consumer (three.js r175)</title>
<style>
body { background:#14161a; color:#dfe3e8; font:13px/1.5 ui-monospace,Menlo,monospace; padding:16px; }
.pass { color:#7fd67f; } .fail { color:#ff6b6b; } h1 { font-size:15px; color:#9fb4c7; }
</style>
<h1>GLB verification — loaded by three.js GLTFLoader, not Blender</h1>
<pre id="out">loading…</pre>
<script type="importmap">
{ "imports": { "three": "/world/vendor/three.module.js",
"three/addons/": "/world/vendor/addons/" } }
</script>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const out = document.getElementById('out');
const log = [];
const say = (s, cls) => {
log.push(s);
out.innerHTML += `<span class="${cls || ''}">${s}</span>\n`;
console.log(s);
};
const report = await (await fetch('/asset_report.json')).json();
const loader = new GLTFLoader();
let fails = 0;
say(`three.js r${THREE.REVISION} | ${report.assets.length} assets\n`);
for (const a of report.assets) {
let gltf = null;
for (const dir of ['/models/', '/models/debris/']) {
try { gltf = await loader.loadAsync(`${dir}${a.name}_v1.glb`); break; } catch (e) {}
}
if (!gltf) { say(`[FAIL] ${a.name.padEnd(16)} could not load`, 'fail'); fails++; continue; }
const size = new THREE.Vector3();
new THREE.Box3().setFromObject(gltf.scene).getSize(size);
// The whole point of this page. Blender is Z-up, glTF is Y-up, so a Blender
// asset measuring (dx, dy, dz) MUST arrive here as (dx, dz, dy). A Blender
// re-import can never catch a broken export_yup — it just flips it back.
const [bx, by, bz] = a.dims;
const exp = [bx, bz, by];
const got = [size.x, size.y, size.z];
const axisOk = got.every((v, i) => Math.abs(v - exp[i]) < 0.02);
const names = [];
gltf.scene.traverse(o => names.push(o.name));
const missing = a.nodes.filter(n => !names.includes(n));
const ok = axisOk && missing.length === 0;
if (!ok) fails++;
say(`[${ok ? 'PASS' : 'FAIL'}] ${a.name.padEnd(16)} ` +
`${got.map(v => v.toFixed(2)).join(' x ')} m (Y-up)`, ok ? 'pass' : 'fail');
if (!axisOk) say(` axis/scale: expected ${exp.map(v => v.toFixed(2)).join(' x ')}`, 'fail');
if (missing.length) say(` nodes lost in three.js: ${missing.join(', ')}`, 'fail');
}
// Empties are the risky part: glTF has no "empty", they arrive as bare Object3D
// nodes, and exporters have been known to prune childless ones. Anchors ARE the
// contract, so prove one survives with a usable world position.
const t = await loader.loadAsync('/models/tree_gum_01_v1.glb');
const anchor = t.scene.getObjectByName('branch_anchor_01');
if (anchor) {
t.scene.updateWorldMatrix(true, true);
const p = new THREE.Vector3().setFromMatrixPosition(anchor.matrixWorld);
const upright = p.y > 1.0 && p.y < 6.0;
if (!upright) fails++;
say(`\n[${upright ? 'PASS' : 'FAIL'}] branch_anchor_01 world pos ` +
`(${p.x.toFixed(2)}, ${p.y.toFixed(2)}, ${p.z.toFixed(2)}) — ` +
`${upright ? 'height is on +Y, anchors are usable' : 'height is NOT on +Y!'}`,
upright ? 'pass' : 'fail');
} else { fails++; say('\n[FAIL] branch_anchor_01 missing entirely', 'fail'); }
say(`\nSUMMARY: ${fails === 0 ? 'ALL PASS IN THREE.JS' : fails + ' FAILURES'}`,
fails === 0 ? 'pass' : 'fail');
window.__done = true; window.__fails = fails;
</script>

View File

@ -0,0 +1,328 @@
{
"blender": "5.1.2",
"assets": [
{
"name": "ref_capsule",
"dims": [
0.4,
0.4,
1.7
],
"tris": 220,
"nodes": [
"head_height",
"ref_capsule",
"ref_capsule_mesh"
],
"status": "PASS",
"problems": []
},
{
"name": "tree_gum_01",
"dims": [
4.5522,
4.956,
7.9702
],
"tris": 396,
"nodes": [
"branch_anchor_01",
"branch_anchor_02",
"branch_anchor_03",
"canopy_01",
"canopy_02",
"canopy_03",
"tree_gum_01",
"trunk"
],
"status": "PASS",
"problems": []
},
{
"name": "tree_gum_02",
"dims": [
3.8871,
2.7787,
5.4972
],
"tris": 288,
"nodes": [
"branch_anchor_01",
"branch_anchor_02",
"canopy_01",
"canopy_02",
"tree_gum_02",
"trunk"
],
"status": "PASS",
"problems": []
},
{
"name": "fence_post",
"dims": [
0.13,
0.13,
2.03
],
"tris": 24,
"nodes": [
"fence_post",
"post"
],
"status": "PASS",
"problems": []
},
{
"name": "fence_panel",
"dims": [
2.4,
0.054,
1.8194
],
"tris": 324,
"nodes": [
"fence_panel",
"palings",
"rails"
],
"status": "PASS",
"problems": []
},
{
"name": "gate",
"dims": [
1.045,
0.0615,
1.75
],
"tris": 220,
"nodes": [
"gate",
"gate_frame",
"gate_palings",
"hinge_axis",
"hinges"
],
"status": "PASS",
"problems": []
},
{
"name": "house_yardside",
"dims": [
9.2,
1.0547,
2.9
],
"tris": 200,
"nodes": [
"door",
"fascia",
"fascia_anchor_01",
"fascia_anchor_02",
"fascia_anchor_03",
"gutter",
"house_yardside",
"roof",
"wall",
"window"
],
"status": "PASS",
"problems": []
},
{
"name": "shed_01",
"dims": [
2.58,
1.9708,
2.2224
],
"tris": 96,
"nodes": [
"door_anchor",
"doors",
"roof",
"shed_01",
"shell"
],
"status": "PASS",
"problems": []
},
{
"name": "shed_table",
"dims": [
1.6,
0.6,
0.9
],
"tris": 72,
"nodes": [
"pickup_anchor",
"shed_table",
"table_frame",
"table_top"
],
"status": "PASS",
"problems": []
},
{
"name": "garden_bed",
"dims": [
3.0,
1.2,
0.8609
],
"tris": 2580,
"nodes": [
"bed",
"garden_bed",
"plants_dead",
"plants_full",
"plants_tattered",
"soil"
],
"status": "PASS",
"problems": []
},
{
"name": "sail_post",
"dims": [
0.507,
0.52,
4.0327
],
"tris": 528,
"nodes": [
"footing",
"pad_eye",
"post",
"rake_pivot",
"sail_post",
"top_anchor"
],
"status": "PASS",
"problems": []
},
{
"name": "ladder_01",
"dims": [
0.455,
0.075,
3.0
],
"tris": 276,
"nodes": [
"ladder",
"ladder_01",
"ladder_base",
"ladder_top"
],
"status": "PASS",
"problems": []
},
{
"name": "shackle",
"dims": [
0.0569,
0.019,
0.0744
],
"tris": 560,
"nodes": [
"bow",
"pin",
"shackle"
],
"status": "PASS",
"problems": []
},
{
"name": "carabiner",
"dims": [
0.049,
0.009,
0.1027
],
"tris": 476,
"nodes": [
"body",
"carabiner",
"gate"
],
"status": "PASS",
"problems": []
},
{
"name": "turnbuckle",
"dims": [
0.0292,
0.0341,
0.1955
],
"tris": 728,
"nodes": [
"body",
"eye_a",
"eye_b",
"turnbuckle"
],
"status": "PASS",
"problems": []
},
{
"name": "tramp_01",
"dims": [
2.9555,
2.9555,
0.78
],
"tris": 976,
"nodes": [
"legs",
"mat",
"pad",
"rim",
"tramp_01"
],
"status": "PASS",
"problems": []
}
],
"debris": [
{
"file": "BlueCrate_v2.glb",
"dims": [
0.36,
0.36,
0.29
],
"sane": true
},
{
"file": "BlackTub_v2.glb",
"dims": [
0.36,
0.54,
0.2
],
"sane": true
},
{
"file": "WhiteTub_v2.glb",
"dims": [
0.36,
0.54,
0.2
],
"sane": true
},
{
"file": "WoodenBin_v2.glb",
"dims": [
0.35,
0.36,
0.31
],
"sane": true
}
]
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@ -0,0 +1,164 @@
"""Build web/world/models/player_anims.glb -- one anim-only GLB carrying every clip the player needs.
Run on the M1 Ultra (the FBX libraries live there). The OUTPUT glb is committed per the copies rule,
so the game itself never needs this box:
scp tools/character/build_player_anims.py johnking@100.91.239.7:~/Documents/shades-laneD-out/
ssh johnking@100.91.239.7 '/Applications/Blender.app/Contents/MacOS/Blender -b --factory-startup \
-P ~/Documents/shades-laneD-out/build_player_anims.py'
scp johnking@100.91.239.7:~/Documents/shades-laneD-out/player_anims.glb web/world/models/
This is the 90sDJsim peds/idle.glb + peds/walk.glb shape (an anim-only clip carrier, no mesh),
extended to the clip set SHADES needs. The character itself is a byte copy of a ped -- see MODELS.md.
WHY NOT merge the clips onto the ped, character_kit/scripts/merge_anims.py style (which is what
PLAN3D 5-D.1 assumes):
The ped GLBs encode their metre scale as a NODE scale of 0.01 on mixamorig*:Hips, with every child
bone in centimetres (Spine T=+10.05, LeftLeg T=+42.8). Blender bones have no rest scale, so the
glTF importer silently drops that 0.01: straight after import the armature already reads
Hips.head_local=(0,-0.03,0.99) in METRES while HeadTop_End=(0,-4.39,76.88) is in CENTIMETRES, and
LeftToe_End sits 96 m under the floor. The rig is exploded before a single clip is merged -- the
peds cannot round-trip through Blender at all. (merge_anims.py works because its base,
Hum_M_1_mixamo.fbx, is an FBX carrying no such trick -- and that base is exactly why dancer.glb
lands 30x small, head bone at 0.0563 m.)
So: never rebuild the character. Ship the ped untouched and carry the clips beside it.
Bind pose here is deliberately IRRELEVANT: the carrier rig exists only to hold actions. player.js
lifts g.animations off this file, canonicalises the bone namespace, and keeps rotation tracks only
(quaternions are scale-invariant, so a carrier at any scale retargets cleanly onto the ped). This
file's own skeleton is never rendered and its scale is never trusted.
"""
import bpy, os, re, sys
HOME = os.path.expanduser("~")
ANIM = f"{HOME}/Documents/3D=models/animations"
FBX = f"{HOME}/Documents/FBX"
OUT = f"{HOME}/Documents/shades-laneD-out/player_anims.glb"
# (clip name in-game, source fbx). These names are the contract with player.js CLIPS.
# Locomotion is in-place; the game translates the player itself (PLAN3D 5-D.2).
CLIPS = [
("Idle", f"{ANIM}/Happy Idle.fbx"),
("Walk", f"{ANIM}/Walk.fbx"),
("Run", f"{FBX}/Running.fbx"),
("Falling", f"{FBX}/Falling.fbx"), # limb flail; player.js pitches the root itself
("CrouchToStand", f"{FBX}/Crouch To Stand.fbx"),
("Reaction", f"{FBX}/Reaction.fbx"), # stagger on a debris glance
]
PREFIX_RE = re.compile(r"mixamorig\d*:")
def imported(fn):
"""Run an import op; return (new objects, new actions)."""
before_o, before_a = set(bpy.data.objects), set(bpy.data.actions)
fn()
return set(bpy.data.objects) - before_o, list(set(bpy.data.actions) - before_a)
# Blender 4.4+ ("slotted actions") moved fcurves from action.fcurves into
# action.layers[].strips[].channelbags[].fcurves, and dropped action.fcurves outright by 5.0
# (which is why merge_anims.py no longer runs there as written). Support both layouts.
def fcurve_owners(act):
"""Yield (owning collection, fcurve) so callers can both read and remove."""
if hasattr(act, "fcurves"):
for fc in list(act.fcurves):
yield act.fcurves, fc
return
for layer in act.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for fc in list(bag.fcurves):
yield bag.fcurves, fc
def fcurve_groups(act):
if hasattr(act, "groups"):
yield from act.groups
return
for layer in act.layers:
for strip in layer.strips:
for bag in strip.channelbags:
yield from bag.groups
bpy.ops.wm.read_factory_settings(use_empty=True)
print("\n===BUILD===")
carrier = None
prefix = None
valid = set()
actions = []
for name, src in CLIPS:
if not os.path.exists(src):
print(f" ! MISSING {name:<14} {src}")
continue
objs, acts = imported(lambda s=src: bpy.ops.import_scene.fbx(filepath=s, automatic_bone_orientation=True))
if not acts:
print(f" ! NO ACTION {name:<12} {os.path.basename(src)}")
keep = None
if carrier is None: # the first clip donates its armature as the carrier; its meshes are dropped
keep = next((o for o in objs if o.type == "ARMATURE"), None)
if keep is None:
sys.exit(f"FATAL: no armature in {src}")
carrier = keep
m = next((PREFIX_RE.match(b.name) for b in carrier.pose.bones if PREFIX_RE.match(b.name)), None)
if not m:
sys.exit(f"FATAL: carrier has no mixamorig namespace: {[b.name for b in carrier.pose.bones][:4]}")
prefix = m.group(0)
valid = {b.name for b in carrier.pose.bones}
print(f"carrier : {os.path.basename(src)} armature={carrier.name} "
f"bones={len(valid)} namespace={prefix!r}")
for act in acts:
act.name = name
act.use_fake_user = True
kept = dropped = 0
for coll, fc in fcurve_owners(act):
fc.data_path = PREFIX_RE.sub(prefix, fc.data_path) # retarget onto the carrier namespace
bone = re.search(r'pose\.bones\["([^"]+)"\]', fc.data_path)
if bone and bone.group(1) not in valid:
coll.remove(fc)
dropped += 1
else:
kept += 1
for g in fcurve_groups(act):
g.name = PREFIX_RE.sub(prefix, g.name)
actions.append(act)
print(f" + {name:<14} {act.frame_range[1] - act.frame_range[0]:6.1f}f fcurves={kept}"
+ (f" (dropped {dropped} unbindable)" if dropped else ""))
for o in objs: # keep the action (and the carrier); bin every mesh and every donor rig
if o is not keep:
bpy.data.objects.remove(o, do_unlink=True)
if not actions:
sys.exit("FATAL: no clips merged")
# --- one NLA track per action: the glTF exporter emits one animation per track ---
if not carrier.animation_data:
carrier.animation_data_create()
carrier.animation_data.action = None
for act in actions:
tr = carrier.animation_data.nla_tracks.new()
tr.name = act.name
strip = tr.strips.new(act.name, int(act.frame_range[0]), act)
# Blender 4.4+ slotted actions: a strip with no slot exports as an empty animation
if hasattr(strip, "action_slot") and strip.action_slot is None and getattr(act, "slots", None):
strip.action_slot = act.slots[0]
print(f"NLA tracks: {len(carrier.animation_data.nla_tracks)}")
os.makedirs(os.path.dirname(OUT), exist_ok=True)
bpy.ops.object.select_all(action="DESELECT")
carrier.select_set(True)
bpy.ops.export_scene.gltf(
filepath=OUT, export_format="GLB",
use_selection=True, # carrier only -- no meshes reach the file
export_animations=True,
export_skins=False, # nothing is skinned to this rig; it is a clip carrier
export_animation_mode="NLA_TRACKS",
export_apply=False,
)
print(f"exported {OUT} ({os.path.getsize(OUT) // 1024} kB)")
print("===ENDBUILD===")

38
web/world/index.html Normal file
View File

@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SHADES — yard (M0)</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #9fc4dd; }
canvas { display: block; width: 100%; height: 100%; }
#dev, #help {
position: fixed; left: 10px;
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
color: #eef4f8; text-shadow: 0 1px 2px #0009;
pointer-events: none; user-select: none;
}
#dev { top: 10px; }
#help { bottom: 10px; opacity: .75; }
#banner {
position: fixed; top: 38%; left: 0; right: 0;
text-align: center; pointer-events: none; user-select: none;
font: 700 42px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: .18em; color: #fff; text-shadow: 0 2px 12px #0007;
opacity: 0; transition: opacity .45s ease;
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="banner"></div>
<div id="dev">booting…</div>
<div id="help">WASD move · shift run · RMB drag orbit · wheel zoom · Enter next phase</div>
<script type="module">
import { boot } from './js/main.js';
boot();
</script>
</body>
</html>

176
web/world/js/camera.js Normal file
View File

@ -0,0 +1,176 @@
/**
* SHADES third-person camera rig. Lane A owns this file.
*
* Shoulder-follow by default, orbit while the right mouse button is held.
* Two things here are load-bearing for other lanes:
* - `yaw` is what Lane D's WASD is relative to.
* - the camera collides against world.solids, because the house sits on the
* north edge of a 20 m yard and a naive follow cam walks straight through
* the wall the moment the player stands with their back to it.
*/
import * as THREE from '../vendor/three.module.js';
/** Stand-off kept between the camera and whatever it collided with. */
const WALL_MARGIN = 0.25;
/** Absolute floor on camera-to-head distance. Above the 0.1 near plane. */
const NEAR_FLOOR = 0.15;
/** How far the camera stays above the terrain. */
const GROUND_CLEARANCE = 0.35;
const DEFAULTS = {
distance: 4.5, // meters behind the target
height: 1.55, // look-at height on the player (roughly the head)
shoulder: 0.55, // lateral offset — the "third person" of it
pitch: -0.18, // radians, slightly down
minPitch: -1.15,
maxPitch: 0.55,
yaw: 0.0,
sensitivity: 0.0042, // radians per pixel
followLambda: 9, // position smoothing rate (higher = stiffer)
fov: 62,
};
/**
* @param {HTMLElement} domElement Element that receives the orbit drag.
* @param {object} [opts]
* @returns {import('./contracts.js').CameraRig}
*/
export function createCameraRig(domElement, opts = {}) {
const cfg = { ...DEFAULTS, ...opts };
const object = new THREE.PerspectiveCamera(cfg.fov, 1, 0.1, 400);
object.position.set(0, 3, 8);
let yaw = cfg.yaw;
let pitch = cfg.pitch;
let dragging = false;
let lastX = 0, lastY = 0;
// Smoothed camera position. Seeded on the first update so the camera doesn't
// fly in from the origin on frame one.
const smoothed = new THREE.Vector3();
let seeded = false;
const onContextMenu = (e) => e.preventDefault();
const onPointerDown = (e) => {
if (e.button !== 2) return; // RMB only
dragging = true;
lastX = e.clientX; lastY = e.clientY;
domElement.setPointerCapture?.(e.pointerId);
};
const onPointerMove = (e) => {
if (!dragging) return;
yaw -= (e.clientX - lastX) * cfg.sensitivity;
pitch -= (e.clientY - lastY) * cfg.sensitivity;
pitch = Math.max(cfg.minPitch, Math.min(cfg.maxPitch, pitch));
lastX = e.clientX; lastY = e.clientY;
};
const onPointerUp = (e) => {
if (e.button !== 2) return;
dragging = false;
domElement.releasePointerCapture?.(e.pointerId);
};
const onWheel = (e) => {
cfg.distance = Math.max(2, Math.min(9, cfg.distance + Math.sign(e.deltaY) * 0.4));
};
domElement.addEventListener('contextmenu', onContextMenu);
domElement.addEventListener('pointerdown', onPointerDown);
domElement.addEventListener('pointermove', onPointerMove);
domElement.addEventListener('pointerup', onPointerUp);
domElement.addEventListener('wheel', onWheel, { passive: true });
const target = new THREE.Vector3();
const desired = new THREE.Vector3();
const dir = new THREE.Vector3();
const ray = new THREE.Raycaster();
ray.far = 20;
/** @type {THREE.Object3D[]} */
let solids = [];
/** @type {(x:number, z:number) => number} */
let groundAt = () => -Infinity;
return {
object,
get yaw() { return yaw; },
set yaw(v) { yaw = v; },
get pitch() { return pitch; },
/**
* Obstacle meshes the camera must not pass through. NOT the ground see
* setGround(). Raycasting a 4800-triangle terrain every frame to learn
* something world.heightAt() answers in closed form is the kind of waste
* that only shows up once four other systems are also running.
*/
setSolids(list) { solids = list ?? []; },
/** @param {(x:number, z:number) => number} fn Pass world.heightAt. */
setGround(fn) { groundAt = fn ?? (() => -Infinity); },
/**
* @param {number} dt
* @param {THREE.Vector3} targetPos Player feet position.
*/
update(dt, targetPos) {
target.set(targetPos.x, targetPos.y + cfg.height, targetPos.z);
// Orbit offset, then slide sideways for the over-the-shoulder framing.
const cp = Math.cos(pitch);
dir.set(Math.sin(yaw) * cp, -Math.sin(pitch), Math.cos(yaw) * cp).normalize();
desired.copy(target).addScaledVector(dir, cfg.distance);
desired.x += Math.cos(yaw) * cfg.shoulder;
desired.z -= Math.sin(yaw) * cfg.shoulder;
// Collision: cast from the head toward where the camera wants to be and
// pull in to the first thing in the way.
if (solids.length) {
const toCam = desired.clone().sub(target);
const dist = toCam.length();
ray.set(target, toCam.divideScalar(dist || 1));
ray.far = dist;
const hit = ray.intersectObjects(solids, true)[0];
if (hit) {
// The wall wins. A comfortable stand-off distance is a preference; a
// camera inside the house is a bug, so when the two disagree the
// geometry gets the last word and the player eats a shoulder-filling
// close-up instead. (Getting this backwards — clamping UP to a
// minimum distance after the raycast — is what put the camera 17 cm
// inside the north wall the first time round.)
const safe = Math.min(dist, hit.distance - WALL_MARGIN);
desired.copy(target).addScaledVector(ray.ray.direction, Math.max(NEAR_FLOOR, safe));
}
}
// Ground is handled analytically rather than by raycast: exact, can't be
// tunnelled through, and free.
desired.y = Math.max(desired.y, groundAt(desired.x, desired.z) + GROUND_CLEARANCE);
if (!seeded) { smoothed.copy(desired); seeded = true; }
// Exponential smoothing that is correct at any dt (not the usual
// frame-rate-dependent lerp).
const a = 1 - Math.exp(-cfg.followLambda * dt);
smoothed.lerp(desired, a);
object.position.copy(smoothed);
object.lookAt(target);
},
/** Keep the projection square with the canvas. */
resize(width, height) {
object.aspect = width / Math.max(1, height);
object.updateProjectionMatrix();
},
dispose() {
domElement.removeEventListener('contextmenu', onContextMenu);
domElement.removeEventListener('pointerdown', onPointerDown);
domElement.removeEventListener('pointermove', onPointerMove);
domElement.removeEventListener('pointerup', onPointerUp);
domElement.removeEventListener('wheel', onWheel);
},
};
}

344
web/world/js/contracts.js Normal file
View File

@ -0,0 +1,344 @@
/**
* SHADES shared contracts. THE integration spine.
*
* Owner: Lane A. This file changes ONLY by agreement if you need a new shape,
* post the need in THREADS.md and let Lane A land it. Everything else in the
* game talks through the types and constants here.
*
* House rules encoded in this file:
* - Units are meters, +Y is up, world origin is yard centre on the ground.
* North is -Z (the house edge), south is +Z, east is +X.
* - Sim modules (sail.js, weather.js, debris.js) take (dt, t) and are pure in
* those: no Date.now(), no Math.random(), no rAF. selftest.html fast-forwards
* storms by calling step() in a tight fixed-dt loop, which only works if the
* sim is deterministic. Use rng() below when you need randomness.
*/
import * as THREE from '../vendor/three.module.js';
// ---------------------------------------------------------------------------
// Units & tuning constants
// ---------------------------------------------------------------------------
/** Sim tick. Every sim module steps at exactly this dt. */
export const FIXED_DT = 1 / 60;
/** Yard footprint in meters. x spans ±WIDTH/2, z spans ±DEPTH/2. */
export const YARD = { width: 30, depth: 20 };
/** Storm length in seconds (prototype value — storm JSON may override). */
export const STORM_LEN = 90;
/** Prep budget in dollars (prototype value). */
export const START_BUDGET = 80;
/** Cost of one spare shackle carried into the storm. */
export const SPARE_COST = 15;
/**
* Hardware tiers, ported from prototype/game.js.
*
* `rating` is nominal kN. The ABSOLUTE numbers are placeholders inherited from
* the 2D prototype's load scale Lane B owns retuning them against the 3D
* cloth's real load output. What must survive retuning is the SHAPE: three
* tiers, roughly 1x / 2x / 4.5x strength at 1x / 3x / 6x price, so a mixed rig
* is always the interesting choice and one dodgy corner is always affordable.
*/
export const HARDWARE = [
{ name: 'carabiner', cost: 5, rating: 9, color: 0xe2b04a },
{ name: 'shackle', cost: 15, rating: 19, color: 0xc8d2d8 },
{ name: 'rated shackle', cost: 30, rating: 40, color: 0x7ee0ff },
];
/** Game phases, in loop order. */
export const PHASES = ['forecast', 'prep', 'storm', 'aftermath'];
// ---------------------------------------------------------------------------
// Determinism helpers
// ---------------------------------------------------------------------------
/**
* mulberry32 small, fast, seeded PRNG. Use this instead of Math.random() in
* any sim code so selftest runs reproduce byte-for-byte.
* @param {number} seed
* @returns {() => number} next float in [0,1)
*/
export function rng(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Tiny event emitter. Backs `game.on()` and `sailRig.events`. */
export class Emitter {
#handlers = new Map();
/**
* @param {string} type
* @param {(payload:any) => void} fn
* @returns {() => void} unsubscribe
*/
on(type, fn) {
if (!this.#handlers.has(type)) this.#handlers.set(type, new Set());
this.#handlers.get(type).add(fn);
return () => this.off(type, fn);
}
off(type, fn) {
this.#handlers.get(type)?.delete(fn);
}
emit(type, payload) {
for (const fn of this.#handlers.get(type) ?? []) fn(payload);
}
}
// ---------------------------------------------------------------------------
// The contracts themselves
// ---------------------------------------------------------------------------
/**
* WIND the shared primitive. weather.js (Lane C) implements it; world, sail,
* player and debris all consume it. Everything that moves in this game moves
* because of a number that came out of wind.sample().
*
* @typedef {object} Wind
* @property {(pos: THREE.Vector3, t: number) => THREE.Vector3} sample
* Wind velocity in m/s at a world position and time. Includes base curve,
* gusts, direction change, spatial noise and local effects (tree wind
* shadows). MUST be pure in (pos, t). Returns a vector the caller may not
* mutate treat it as read-only, or clone before writing.
* @property {(t: number) => ({eta:number, dir:number, power:number}|null)} gustTelegraph
* The gust that is coming but has not hit yet, or null. `eta` is seconds
* until the ramp starts, `dir` is radians in the XZ plane, `power` is the
* peak speed in m/s the gust will add. Drives the HUD warning, the grass
* wave and the audio cue. Contract: eta is never less than 1.2 s when the
* telegraph first appears the player must always have time to react.
*/
/**
* WORLD the yard. Lane A implements; everyone consumes.
*
* @typedef {object} World
* @property {Anchor[]} anchors Every riggable point in the yard.
* @property {(x:number, z:number) => number} heightAt
* Ground height in meters at a world XZ. Pure, cheap, matches the terrain
* mesh. Lane D clamps the player to it; Lane C bounces debris off it.
* @property {{x:number, z:number, w:number, d:number}} gardenBed
* The thing you are protecting: an axis-aligned ground rect, centre (x,z),
* size (w,d) in meters. Pass it to sailRig.coverageOver().
* @property {THREE.Vector3} sunDir
* Unit vector pointing FROM the ground TOWARD the sun. For a shade test,
* raycast origin=groundPoint direction=sunDir: a hit means shaded.
* @property {THREE.Object3D[]} solids
* Obstacle meshes the camera and player collide against: house, trunks,
* posts, fence. The GROUND IS NOT IN HERE use heightAt() for that. It is
* exact, it can't be tunnelled through, and it costs nothing.
* @property {(dt:number, t:number) => void} update
*/
/**
* ANCHOR a tower slot. Where a sail corner can be attached.
*
* @typedef {object} Anchor
* @property {string} id Stable, e.g. 'h1', 't1', 'p2'.
* @property {THREE.Vector3} pos REST position. Does not move.
* @property {'house'|'tree'|'post'} type
* @property {(t:number) => THREE.Vector3} sway
* ABSOLUTE world position at time t, including wind sway. NOT an offset
* this is the value you pin a cloth corner to:
* node.copy(anchor.sway(t))
* For house and post anchors this equals `pos`. For tree anchors it wanders,
* and that wander is dynamic load the reason tree anchors are dangerous.
* Pure in t (given wind is pure in t). Returns a shared vector: clone before
* storing.
*/
/**
* SAIL RIG Lane B implements.
*
* @typedef {object} SailRig
* @property {Corner[]} corners
* @property {(anchorIds: string[], hwChoices: object[], tension: number) => void} attach
* anchorIds has exactly 4 entries; the rig re-orders them into ring order by
* angle around their centroid. tension scales spring rest lengths, 0.61.4
* (low = loose and floggy, high = drum tight and shock-loaded).
* @property {(dt:number, wind:Wind, t:number) => void} step Fixed dt. Deterministic.
* @property {(rect: {x:number,z:number,w:number,d:number}) => number} coverageOver
* Ground-projected shade over a rect, 0..1.
* @property {Emitter} events Emits 'break' and 'repair' as {type, corner}.
*/
/**
* @typedef {object} Corner
* @property {string} anchorId
* @property {{name:string, cost:number, rating:number}} hw
* @property {number} load Smoothed, same units as hw.rating.
* @property {boolean} broken
*/
/**
* PLAYER Lane D implements.
*
* @typedef {object} Player
* @property {THREE.Vector3} pos
* @property {object|null} carrying One item, or null. Hands-full rule.
* @property {boolean} busy True during a hold-E action or knockdown.
* @property {(dt:number, t:number) => void} update
* Driven by main.js's fixed-dt loop. (Lane A addition to PLAN3D §4, which
* listed only the read-only fields main.js has to step the player from
* somewhere. Logged in THREADS.md.)
*/
/**
* INTERACT Lane D implements. Lane B and E register the touchable things.
*
* @typedef {object} Interact
* @property {(spec: InteractSpec) => (() => void)} register Returns an unregister fn.
*/
/**
* @typedef {object} InteractSpec
* @property {string} id
* @property {THREE.Vector3} pos
* @property {number} radius Meters.
* @property {number} holdSecs 0 for instant.
* @property {string} label Shown in the prompt, e.g. 're-rig corner'.
* @property {() => boolean} canUse Gate on spares, busy, carrying.
* @property {() => void} onDone
*/
/**
* CAMERA Lane A implements. Lane D needs `yaw` for camera-relative movement.
*
* @typedef {object} CameraRig
* @property {THREE.PerspectiveCamera} object
* @property {number} yaw Radians. WASD is relative to this.
* @property {(dt:number, targetPos:THREE.Vector3) => void} update
*/
/**
* GAME Lane A implements.
*
* @typedef {object} Game
* @property {'forecast'|'prep'|'storm'|'aftermath'} phase
* @property {(type:'phaseChange', fn:(p:{from:string,to:string}) => void) => (() => void)} on
* NOTE: PLAN3D §4 writes this as `game.on(phaseChange)`. It is implemented as
* Emitter-style `on(type, fn)` with type 'phaseChange', for consistency with
* sailRig.events. (Lane A clarification, logged in THREADS.md.)
*/
// ---------------------------------------------------------------------------
// Shape checking — used by selftest to catch contract drift at merge time
// ---------------------------------------------------------------------------
/**
* Required members per contract, as data. `'*'` means "present, any type".
* Lane A asserts these in selftest after every merge; that is how we find out
* a lane's module drifted before it breaks someone else's lane.
*/
export const CONTRACT = {
wind: { sample: 'function', gustTelegraph: 'function' },
world: { anchors: 'object', heightAt: 'function', gardenBed: 'object', sunDir: 'object', solids: 'object', update: 'function' },
sailRig: { corners: 'object', attach: 'function', step: 'function', coverageOver: 'function', events: 'object' },
player: { pos: 'object', carrying: '*', busy: '*', update: 'function' },
interact: { register: 'function' },
camera: { object: 'object', yaw: 'number', update: 'function' },
game: { phase: 'string', on: 'function' },
};
/**
* @param {string} name A key of CONTRACT.
* @param {object} obj The implementation to check.
* @returns {string[]} Human-readable problems; empty means it conforms.
*/
export function checkContract(name, obj) {
const spec = CONTRACT[name];
if (!spec) return [`unknown contract '${name}'`];
if (!obj) return [`${name}: implementation is ${obj}`];
const problems = [];
for (const [key, want] of Object.entries(spec)) {
const got = typeof obj[key];
if (got === 'undefined') problems.push(`${name}.${key} missing`);
else if (want !== '*' && got !== want) problems.push(`${name}.${key} is ${got}, want ${want}`);
}
return problems;
}
// ---------------------------------------------------------------------------
// Stub wind — so B, D and the HUD can run before Lane C lands weather.js
// ---------------------------------------------------------------------------
/**
* A deterministic stand-in for weather.js that satisfies the Wind contract.
* It ports the prototype's gust shape (telegraph 1.5s ramp 0.8s hold 1.7s
* fade 1.0s) and its base ramp, but is uniform in space and has no direction
* change, no tree shadows and no noise. Lane C replaces it wholesale; nobody
* should tune against it.
*
* @param {object} [opts]
* @param {number} [opts.seed=1]
* @param {number} [opts.stormLen=STORM_LEN]
* @param {boolean} [opts.calm=false] Calm-day breeze only no storm ramp, no gusts.
* @returns {Wind}
*/
export function createStubWind(opts = {}) {
const { seed = 1, stormLen = STORM_LEN, calm = false } = opts;
const TELEGRAPH = 1.5, RAMP = 0.8, HOLD = 1.7, FADE = 1.0;
const CYCLE = TELEGRAPH + RAMP + HOLD + FADE; // 5.0 s, matches prototype
// Precompute the whole gust schedule up front: that keeps sample() pure in t
// (no hidden state advancing per call) so selftest can sample out of order.
const rand = rng(seed);
const gusts = [];
for (let t = 3; t < stormLen + CYCLE; ) {
const p = Math.min(1, t / stormLen);
gusts.push({ start: t, power: 12 + rand() * 16 + 10 * p });
t += CYCLE + 5 + rand() * 7;
}
const dirAt = (t) => 0.9 + 0.25 * Math.sin(t * 0.13);
/** Gust contribution in m/s at time t, plus the gust in flight. */
function gustAt(t) {
for (const g of gusts) {
const gt = t - g.start;
if (gt < 0 || gt >= CYCLE) continue;
let mag;
if (gt < TELEGRAPH) mag = 0;
else if (gt < TELEGRAPH + RAMP) mag = g.power * (gt - TELEGRAPH) / RAMP;
else if (gt < TELEGRAPH + RAMP + HOLD) mag = g.power;
else mag = g.power * (CYCLE - gt) / FADE;
return { mag, g, gt };
}
return { mag: 0, g: null, gt: 0 };
}
const out = new THREE.Vector3();
return {
sample(_pos, t) {
if (calm) {
const dir = dirAt(t);
const s = 4 + 0.6 * Math.sin(t * 0.7);
return out.set(Math.cos(dir) * s, 0, Math.sin(dir) * s);
}
const base = 8 + 26 * Math.min(1, (t / stormLen) * 1.6);
const speed = base + gustAt(t).mag;
const dir = dirAt(t);
return out.set(Math.cos(dir) * speed, 0, Math.sin(dir) * speed);
},
gustTelegraph(t) {
if (calm) return null;
const { g, gt } = gustAt(t);
if (!g || gt >= TELEGRAPH) return null; // already landed: not a warning any more
return { eta: TELEGRAPH - gt, dir: dirAt(t), power: g.power };
},
};
}

239
web/world/js/main.js Normal file
View File

@ -0,0 +1,239 @@
/**
* SHADES boot, game loop, phase machine. Lane A owns this file.
*
* Nothing is auto-run on import: index.html calls boot(). That keeps createGame()
* importable from selftest.html, which must never construct a WebGLRenderer.
*
* The loop is a fixed-dt accumulator. Sim modules only ever see FIXED_DT, never
* a real frame delta that is the whole reason selftest can fast-forward a 90 s
* storm in a few milliseconds and get the same numbers the player got.
*/
import * as THREE from '../vendor/three.module.js';
import { FIXED_DT, PHASES, STORM_LEN, YARD, Emitter, createStubWind } from './contracts.js';
import { createWorld, heightAt } from './world.js';
import { createCameraRig } from './camera.js';
// ---------------------------------------------------------------------------
// Phase machine
// ---------------------------------------------------------------------------
/**
* forecast prep storm aftermath forecast.
* @returns {import('./contracts.js').Game}
*/
export function createGame() {
const emitter = new Emitter();
let phase = 'forecast';
let phaseT = 0;
return {
get phase() { return phase; },
/** Seconds since this phase began. The storm clock. */
get phaseT() { return phaseT; },
on(type, fn) { return emitter.on(type, fn); },
/** @param {'forecast'|'prep'|'storm'|'aftermath'} next */
setPhase(next) {
if (!PHASES.includes(next)) throw new Error(`unknown phase '${next}'`);
if (next === phase) return;
const from = phase;
phase = next;
phaseT = 0;
emitter.emit('phaseChange', { from, to: next });
},
/** Advance to the next phase in loop order. */
advance() {
this.setPhase(PHASES[(PHASES.indexOf(phase) + 1) % PHASES.length]);
},
tick(dt) {
phaseT += dt;
// The storm is on a timer; every other phase waits for the player.
if (phase === 'storm' && phaseT >= STORM_LEN) this.setPhase('aftermath');
},
};
}
// ---------------------------------------------------------------------------
// M0 placeholder player
// ---------------------------------------------------------------------------
/**
* A 1.7 m capsule that walks. This exists ONLY so the camera has something to
* follow and the yard scale is legible before Lane D lands.
*
* Lane D: replace the call site in boot() with your player.js factory the
* shape you need to satisfy is `Player` in contracts.js ({pos, carrying, busy,
* update}) then delete this function. Everything else in this file already
* talks to you through that contract, so nothing else should need to change.
*/
function createPlaceholderPlayer(scene, world, cameraRig) {
const WALK = 2.2, RUN = 4.5; // m/s
const mesh = new THREE.Mesh(
new THREE.CapsuleGeometry(0.28, 1.14, 4, 12),
new THREE.MeshStandardMaterial({ color: 0xffd27a, roughness: 0.7 }),
);
mesh.name = 'player_placeholder';
mesh.castShadow = true;
scene.add(mesh);
const keys = new Set();
const onDown = (e) => {
keys.add(e.key.toLowerCase());
if ([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(e.key.toLowerCase())) e.preventDefault();
};
const onUp = (e) => keys.delete(e.key.toLowerCase());
addEventListener('keydown', onDown);
addEventListener('keyup', onUp);
const pos = new THREE.Vector3(0, heightAt(0, 6), 6);
const move = new THREE.Vector3();
const fwd = new THREE.Vector3();
const right = new THREE.Vector3();
let facing = 0;
const hx = YARD.width / 2 - 0.5, hz = YARD.depth / 2 - 0.5;
return {
pos,
carrying: null,
busy: false,
mesh,
update(dt) {
const yaw = cameraRig.yaw;
// Camera-relative: forward is where the camera is looking, flattened.
fwd.set(-Math.sin(yaw), 0, -Math.cos(yaw));
right.set(Math.cos(yaw), 0, -Math.sin(yaw));
move.set(0, 0, 0);
if (keys.has('w') || keys.has('arrowup')) move.add(fwd);
if (keys.has('s') || keys.has('arrowdown')) move.sub(fwd);
if (keys.has('d') || keys.has('arrowright')) move.add(right);
if (keys.has('a') || keys.has('arrowleft')) move.sub(right);
if (move.lengthSq() > 0) {
move.normalize().multiplyScalar((keys.has('shift') ? RUN : WALK) * dt);
pos.x = Math.max(-hx, Math.min(hx, pos.x + move.x));
pos.z = Math.max(-hz, Math.min(hz, pos.z + move.z));
facing = Math.atan2(move.x, move.z);
}
pos.y = world.heightAt(pos.x, pos.z);
mesh.position.set(pos.x, pos.y + 0.85, pos.z); // capsule centre
mesh.rotation.y = facing;
},
dispose() {
removeEventListener('keydown', onDown);
removeEventListener('keyup', onUp);
},
};
}
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
/**
* @param {object} [opts]
* @param {HTMLCanvasElement} [opts.canvas]
*/
export function boot(opts = {}) {
const canvas = opts.canvas ?? document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
const scene = new THREE.Scene();
// Lane C: swap createStubWind() for your createWeather(). Everything that
// moves reads this one object, so that swap is the whole integration.
const wind = createStubWind({ calm: true });
const world = createWorld(scene, { wind });
const cameraRig = createCameraRig(canvas);
cameraRig.setSolids(world.solids);
cameraRig.setGround(world.heightAt);
const player = createPlaceholderPlayer(scene, world, cameraRig);
const game = createGame();
// --- dev overlay (temporary — Lane A's hud.js replaces it after M0) ----
const hud = document.getElementById('dev');
const banner = document.getElementById('banner');
game.on('phaseChange', ({ to }) => {
if (banner) {
banner.textContent = to.toUpperCase();
banner.style.opacity = '1';
setTimeout(() => { banner.style.opacity = '0'; }, 1400);
}
});
addEventListener('keydown', (e) => {
if (e.key === 'Enter') game.advance();
});
// --- resize ------------------------------------------------------------
function resize() {
const w = canvas.clientWidth || innerWidth;
const h = canvas.clientHeight || innerHeight;
renderer.setSize(w, h, false);
cameraRig.resize(w, h);
}
addEventListener('resize', resize);
resize();
// --- loop --------------------------------------------------------------
const clock = new THREE.Clock();
let acc = 0;
let simT = 0;
let frames = 0, fpsT = 0, fps = 0;
function step(dt, t) {
game.tick(dt);
world.update(dt, t);
player.update(dt, t);
// Lane B: sailRig.step(dt, wind, t) goes here.
// Lane C: debris.step(dt, wind, t) goes here.
}
function frame() {
// Clamped so a background tab or a breakpoint doesn't make the sim try to
// catch up over thousands of steps and lock the page.
const raw = Math.min(0.25, clock.getDelta());
acc += raw;
let guard = 0;
while (acc >= FIXED_DT && guard++ < 60) {
step(FIXED_DT, simT);
simT += FIXED_DT;
acc -= FIXED_DT;
}
cameraRig.update(raw, player.pos);
renderer.render(scene, cameraRig.object);
frames++; fpsT += raw;
if (fpsT >= 0.5) { fps = frames / fpsT; frames = 0; fpsT = 0; }
if (hud) {
const w = wind.sample(player.pos, simT);
hud.textContent =
`${fps.toFixed(0)} fps | phase ${game.phase} ${game.phaseT.toFixed(1)}s | ` +
`wind ${w.length().toFixed(1)} m/s | t ${simT.toFixed(1)}s`;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// Handy for poking at the world from the console.
const api = { renderer, scene, world, cameraRig, player, game, wind, get simT() { return simT; } };
globalThis.SHADES = api;
return api;
}

107
web/world/js/testkit.js Normal file
View File

@ -0,0 +1,107 @@
/**
* SHADES tiny test harness for selftest.html. Lane A owns this file.
*
* Deliberately not a framework. The only rules that matter:
* - No rAF. requestAnimationFrame is throttled to a crawl in a hidden tab,
* and a selftest that only passes while you watch it is not a selftest.
* Drive time with fixedLoop() below.
* - No Date.now(), no Math.random() in anything under test.
*
* Each lane owns js/tests/<lane>.test.js and nobody edits selftest.html.
*/
export function assert(cond, msg = 'assertion failed') {
if (!cond) throw new Error(msg);
}
export function assertEq(actual, expected, msg = '') {
if (!Object.is(actual, expected)) {
throw new Error(`${msg || 'not equal'}: got ${format(actual)}, want ${format(expected)}`);
}
}
export function assertClose(actual, expected, eps = 1e-6, msg = '') {
if (!(Math.abs(actual - expected) <= eps)) {
throw new Error(`${msg || 'not close'}: got ${actual}, want ${expected} ±${eps}`);
}
}
export function assertLess(a, b, msg = '') {
if (!(a < b)) throw new Error(`${msg || 'not less'}: ${a} is not < ${b}`);
}
function format(v) {
if (typeof v === 'number') return String(v);
try { return JSON.stringify(v); } catch { return String(v); }
}
/**
* Drive a sim forward in fixed steps. This is how you fast-forward a storm.
*
* @param {number} seconds Sim seconds to advance.
* @param {number} dt Step size pass contracts.FIXED_DT.
* @param {(dt:number, t:number) => void} fn Called with the time at step start.
*/
export function fixedLoop(seconds, dt, fn) {
const steps = Math.round(seconds / dt);
let t = 0;
for (let i = 0; i < steps; i++) {
fn(dt, t);
t += dt;
}
return t;
}
/** Collects results for one lane. */
export class Suite {
constructor(lane) {
this.lane = lane;
/** @type {{lane:string, label:string, status:'pass'|'fail'|'skip', err?:string, ms:number}[]} */
this.results = [];
}
test(label, fn) {
const t0 = performance.now();
try {
fn();
this.results.push({ lane: this.lane, label, status: 'pass', ms: performance.now() - t0 });
} catch (err) {
this.results.push({
lane: this.lane, label, status: 'fail',
err: err?.message ?? String(err), ms: performance.now() - t0,
});
}
}
/** For a lane that hasn't landed yet. Shows up in the report, doesn't fail it. */
skip(label) {
this.results.push({ lane: this.lane, label, status: 'skip', ms: 0 });
}
}
/**
* @param {[string, (t: Suite) => void|Promise<void>][]} lanes
* @returns {Promise<{pass:number, fail:number, skip:number, ok:boolean, results:object[]}>}
*/
export async function runAll(lanes) {
const results = [];
for (const [lane, run] of lanes) {
const suite = new Suite(lane);
try {
await run(suite);
} catch (err) {
// A lane whose module fails to import or throws at setup must not take
// the whole report down with it — that's how you lose the signal from
// every other lane during a merge.
suite.results.push({
lane, label: '(suite threw during setup)', status: 'fail',
err: err?.message ?? String(err), ms: 0,
});
}
results.push(...suite.results);
}
const pass = results.filter((r) => r.status === 'pass').length;
const fail = results.filter((r) => r.status === 'fail').length;
const skip = results.filter((r) => r.status === 'skip').length;
return { pass, fail, skip, ok: fail === 0, results };
}

View File

@ -0,0 +1,228 @@
/**
* Lane A selftests contracts, yard layout, anchors, phase machine.
* Lane A owns this file. Other lanes: yours is js/tests/<letter>.test.js.
*/
import * as THREE from '../../vendor/three.module.js';
import { FIXED_DT, STORM_LEN, YARD, checkContract, createStubWind } from '../contracts.js';
import { createWorld, heightAt } from '../world.js';
import { createCameraRig } from '../camera.js';
import { createGame } from '../main.js';
import { assert, assertEq, assertLess, fixedLoop } from '../testkit.js';
/** @param {import('../testkit.js').Suite} t */
export default function run(t) {
const scene = new THREE.Scene();
const world = createWorld(scene, { wind: createStubWind({ calm: true }) });
// --- contract conformance ------------------------------------------------
// These are the merge tripwires: if a lane's module drifts from contracts.js,
// this is where we find out, not three lanes later.
t.test('contract: stub wind conforms', () => {
assertEq(checkContract('wind', createStubWind()).join('; '), '');
});
t.test('contract: world conforms', () => {
assertEq(checkContract('world', world).join('; '), '');
});
t.test('contract: camera rig conforms', () => {
const rig = createCameraRig(document.createElement('div'));
assertEq(checkContract('camera', rig).join('; '), '');
});
t.test('contract: game conforms', () => {
assertEq(checkContract('game', createGame()).join('; '), '');
});
// --- camera --------------------------------------------------------------
t.test('camera keeps a clear line to the player from every angle', () => {
// PLAN3D §5-A acceptance: "camera never clips through house". Stated here
// as the underlying invariant — no solid between the player's head and the
// camera — so it still means something after Lane E swaps the graybox house
// for house_yardside.glb.
const rig = createCameraRig(document.createElement('div'));
rig.setSolids(world.solids);
rig.setGround(heightAt);
const ray = new THREE.Raycaster();
const head = new THREE.Vector3();
const spots = [
[0, -9.4], // backed against the house — the case that caught it
[-5, -9.2], // under a fascia anchor
[-9, 2.6], // hard against a tree trunk
[-6, 7], // hard against a post
[0, 9.2], // against the south fence
];
for (const [x, z] of spots) {
const spot = new THREE.Vector3(x, heightAt(x, z), z);
for (let k = 0; k < 6; k++) {
rig.yaw = (k / 6) * Math.PI * 2;
// 60 steps = 1 s of smoothing at lambda 9, i.e. 99.99% settled. Every
// step is a raycast against the whole yard, so this bound is the
// difference between a 1 s selftest and a 4 s one — and every lane runs
// it after every merge.
for (let i = 0; i < 60; i++) rig.update(1 / 60, spot);
head.set(spot.x, spot.y + 1.55, spot.z);
const cam = rig.object.position;
const d = head.distanceTo(cam);
assert(d > 0.1, `camera collapsed onto the player (${d.toFixed(3)}m) at (${x},${z})`);
ray.set(head, cam.clone().sub(head).divideScalar(d));
ray.far = d - 0.02;
const blocked = ray.intersectObjects(world.solids, true)[0];
assert(
!blocked,
`'${blocked?.object.name || '?'}' sits between the player at (${x},${z}) ` +
`and the camera at yaw ${rig.yaw.toFixed(2)}`,
);
// The ground isn't a solid (heightAt handles it), so the raycast above
// can't speak for it — assert it separately.
assert(
cam.y > heightAt(cam.x, cam.z),
`camera is underground at (${cam.x.toFixed(1)},${cam.z.toFixed(1)}), ` +
`y=${cam.y.toFixed(2)} vs terrain ${heightAt(cam.x, cam.z).toFixed(2)}`,
);
}
}
});
// --- terrain -------------------------------------------------------------
t.test('heightAt is pure and gentle across the whole yard', () => {
for (let x = -15; x <= 15; x += 1.5) {
for (let z = -10; z <= 10; z += 1.5) {
const h = heightAt(x, z);
assertEq(h, heightAt(x, z), `heightAt(${x},${z}) not pure`);
assert(Math.abs(h) < 0.5, `heightAt(${x},${z}) = ${h}: terrain should stay gentle`);
}
}
});
t.test('garden bed sits inside the yard', () => {
const b = world.gardenBed;
assert(Math.abs(b.x) + b.w / 2 < YARD.width / 2, 'bed overhangs east/west');
assert(Math.abs(b.z) + b.d / 2 < YARD.depth / 2, 'bed overhangs north/south');
});
// --- anchors -------------------------------------------------------------
t.test('yard offers 7 anchors: 3 house, 2 tree, 2 post', () => {
const by = (type) => world.anchors.filter((a) => a.type === type).length;
assertEq(by('house'), 3, 'house anchors');
assertEq(by('tree'), 2, 'tree anchors');
assertEq(by('post'), 2, 'post anchors');
const ids = world.anchors.map((a) => a.id);
assertEq(new Set(ids).size, ids.length, `anchor ids not unique: ${ids}`);
});
t.test('sway() returns an absolute position, not an offset', () => {
// If sway ever regresses to returning an offset, the returned point lands
// near the origin instead of near the anchor, and Lane B's cloth corners
// all snap to the middle of the yard. Catch it here.
for (const a of world.anchors) {
const p = a.sway(3.1);
assertLess(p.distanceTo(a.pos), 0.6, `${a.id}.sway() is ${p.length().toFixed(2)}m from origin but should be near pos`);
}
});
t.test('house and post anchors are rigid; tree anchors move', () => {
for (const a of world.anchors.filter((x) => x.type !== 'tree')) {
assertEq(a.sway(0).distanceTo(a.sway(12.5)), 0, `${a.id} should not move`);
}
for (const a of world.anchors.filter((x) => x.type === 'tree')) {
// Sampled at two times a half-period apart; a static tree would score 0.
const spread = a.sway(0).clone().distanceTo(a.sway(0.83));
assert(spread > 1e-4, `${a.id} should sway with the wind, moved ${spread}m`);
}
});
t.test('anchors do not alias each other scratch vectors', () => {
// Two tree anchors handing out the same scratch Vector3 would silently
// corrupt whichever corner was read first.
const t1 = world.anchor('t1'), t2 = world.anchor('t2');
const p1 = t1.sway(2);
const x1 = p1.x;
const p2 = t2.sway(2);
assert(p1 !== p2, 't1 and t2 handed out the same vector object');
assertEq(p1.x, x1, 'reading t2 mutated the vector t1 returned');
});
// --- wind stub -----------------------------------------------------------
t.test('gust telegraph always gives at least 1.2 s of warning', () => {
// The promise the whole storm rests on: the player can always react.
// Lane C must keep this true for the real weather.js.
const wind = createStubWind({ seed: 7 });
let had = false, edges = 0;
fixedLoop(STORM_LEN, FIXED_DT, (dt, time) => {
const tel = wind.gustTelegraph(time);
if (tel && !had) {
edges++;
assert(tel.eta >= 1.2, `telegraph appeared with only ${tel.eta.toFixed(2)}s of warning`);
}
had = !!tel;
});
assert(edges >= 5, `only ${edges} gusts telegraphed in a ${STORM_LEN}s storm — too quiet to test`);
});
t.test('wind stub is deterministic: same seed, same storm', () => {
const a = createStubWind({ seed: 42 });
const b = createStubWind({ seed: 42 });
const p = new THREE.Vector3(1, 1, 1);
for (let time = 0; time < STORM_LEN; time += 0.37) {
const va = a.sample(p, time).clone();
const vb = b.sample(p, time);
assertEq(va.x, vb.x, `x diverged at t=${time}`);
assertEq(va.z, vb.z, `z diverged at t=${time}`);
}
});
t.test('wind stub gets angrier as the storm runs', () => {
const wind = createStubWind({ seed: 3 });
const p = new THREE.Vector3();
assertLess(wind.sample(p, 1).length(), wind.sample(p, STORM_LEN - 1).length());
});
// --- phase machine -------------------------------------------------------
t.test('phases cycle forecast → prep → storm → aftermath → forecast', () => {
const game = createGame();
assertEq(game.phase, 'forecast');
game.advance(); assertEq(game.phase, 'prep');
game.advance(); assertEq(game.phase, 'storm');
game.advance(); assertEq(game.phase, 'aftermath');
game.advance(); assertEq(game.phase, 'forecast');
});
t.test('phaseChange fires once, with from and to', () => {
const game = createGame();
const seen = [];
game.on('phaseChange', (p) => seen.push(p));
game.setPhase('prep');
game.setPhase('prep'); // no-op: already there
assertEq(seen.length, 1, 'phaseChange fired for a no-op transition');
assertEq(seen[0].from, 'forecast');
assertEq(seen[0].to, 'prep');
});
t.test('a 90 s storm ends itself (fast-forwarded, no rAF)', () => {
const game = createGame();
game.setPhase('storm');
fixedLoop(STORM_LEN - 1, FIXED_DT, () => game.tick(FIXED_DT));
assertEq(game.phase, 'storm', 'storm ended early');
fixedLoop(2, FIXED_DT, () => game.tick(FIXED_DT));
assertEq(game.phase, 'aftermath', 'storm never ended');
});
t.test('unknown phase is rejected', () => {
const game = createGame();
let threw = false;
try { game.setPhase('apocalypse'); } catch { threw = true; }
assert(threw, 'setPhase accepted a phase that does not exist');
});
}

View File

@ -0,0 +1,26 @@
/**
* Lane B selftests cloth, corner loads, failure cascade.
*
* Lane B owns this file. Lane A pre-created it so that adding your suite never
* means editing selftest.html if all five lanes shared that file it would be
* the one guaranteed merge conflict in the repo.
*
* The asserts PLAN3D §5-B asks for, once sail.js lands:
* 1. hypar sheds load twisted rig's peak corner load < flat rig's peak,
* same storm, same hardware. This is the thesis of the whole game; if it
* doesn't hold, the wind force is being applied per-node instead of
* per-face.
* 2. cascade break one corner at a fixed t, a neighbour's load spikes 2×.
* 3. determinism two runs, same inputs, byte-equal load traces.
*
* Useful imports when you get there:
* import { FIXED_DT, STORM_LEN, HARDWARE, createStubWind } from '../contracts.js';
* import { assert, assertLess, fixedLoop } from '../testkit.js';
* Drive time with fixedLoop(), never rAF. Use createStubWind({seed}) until
* Lane C's weather.js lands — but don't tune against it, it's uniform in space.
*/
/** @param {import('../testkit.js').Suite} t */
export default function run(t) {
t.skip('sail.js not landed yet — Lane B');
}

View File

@ -0,0 +1,25 @@
/**
* Lane C selftests wind field, storm timelines, rain, debris.
*
* Lane C owns this file. Lane A pre-created it so adding your suite never means
* editing selftest.html.
*
* The asserts PLAN3D §5-C asks for, once weather.js lands:
* 1. gust telegraph lead 1.2 s the promise the storm rests on. Lane A's
* suite already asserts this against the stub wind (see a.test.js,
* 'gust telegraph always gives at least 1.2 s of warning'); lift that test
* onto the real wind.sample and delete the stub version's claim to it.
* 2. wind.sample continuity no frame-to-frame jump beyond a sane bound, or
* the cloth explodes and it looks like Lane B's bug.
* 3. storm JSON schema validation for everything in data/storms/.
*
* Useful imports:
* import { FIXED_DT, STORM_LEN } from '../contracts.js';
* import { assert, assertLess, fixedLoop } from '../testkit.js';
* wind.sample(pos, t) must be pure in (pos, t): selftest samples out of order.
*/
/** @param {import('../testkit.js').Suite} t */
export default function run(t) {
t.skip('weather.js not landed yet — Lane C');
}

View File

@ -0,0 +1,26 @@
/**
* Lane D selftests player state machine and interactions.
*
* Lane D owns this file. Lane A pre-created it so adding your suite never means
* editing selftest.html.
*
* The asserts PLAN3D §5-D asks for, once player.js lands:
* 1. anim state machine table test every state reachable, none stuck
* (idle/walk/run + one-shot interact + knockdown get-up).
* 2. interact radius respects the busy and carrying flags.
*
* Note for integration: main.js currently drives a placeholder capsule that
* satisfies the Player contract ({pos, carrying, busy, update}). When player.js
* lands, Lane A swaps the factory call in boot() and deletes the placeholder
* you shouldn't need to touch main.js yourself. Ping THREADS.md when you're
* ready and Lane A will do the swap.
*
* Useful imports:
* import { FIXED_DT, createStubWind } from '../contracts.js';
* import { assert, assertEq, fixedLoop } from '../testkit.js';
*/
/** @param {import('../testkit.js').Suite} t */
export default function run(t) {
t.skip('player.js not landed yet — Lane D');
}

View File

@ -0,0 +1,160 @@
/**
* Lane E selftests asset sanity.
* Lane E owns this file. Other lanes: yours is js/tests/<letter>.test.js.
*
* Why this exists when build_yard_assets.py already verifies: the Blender side
* re-imports every GLB and asserts dims, tri budget and node names, but it
* CANNOT catch an axis error. Blender exports Z-upY-up and imports Y-upZ-up,
* so a broken `export_yup` flips back on the way in and round-trips green. Only
* a native glTF reader can prove the file is right, and this is the only one in
* the repo. So this suite targets the failures that silently break other lanes:
* 1. the GLB loads at all through the vendored loader;
* 2. it's in metres with its height on +Y a model exported in centimetres
* looks fine alone and absurd next to a person;
* 3. the nodes other lanes query by name survived the export. glTF has no
* "empty" type, so anchors arrive as bare Object3D and are exactly what an
* exporter prunes.
*
* Standalone version with a fuller report: tools/assetcheck/.
*/
import * as THREE from '../../vendor/three.module.js';
import { assert } from '../testkit.js';
// GLTFLoader is imported DYNAMICALLY, below, and that is deliberate.
//
// Every three.js addon imports the bare specifier `three`, which only resolves
// via an <script type="importmap">. No page in this repo has one yet — index.html
// and selftest.html both import `../vendor/three.module.js` by relative path and
// so never needed it. A static import here would throw at module load, and
// selftest.html turns an un-importable lane module into a hard FAIL, which would
// redden Lane A's merge gate over a harness gap rather than a real defect.
//
// So: try it at runtime and skip with an actionable message if it's absent. The
// day the importmap lands this suite lights up on its own, no edit needed.
// Need + exact fix are logged in THREADS.md [E] — it blocks Lane D too, which
// can't load a ped without GLTFLoader/SkeletonUtils.
const LOADER_PATH = '../../vendor/addons/loaders/GLTFLoader.js';
/** Resolve off import.meta.url, not the document — survives selftest.html moving. */
const url = (a) => new URL(`../../models/${a.sub ?? ''}${a.name}_v1.glb`, import.meta.url).href;
/**
* Height ranges rather than exact dims: this guards against unit and axis
* regressions, not against Lane E retuning a silhouette. Exact measurements
* live in tools/blender/asset_report.json. `nodes` are the names other lanes
* query changing one is a contract break and should fail here.
*/
const ASSETS = [
{ name: 'ref_capsule', h: [1.68, 1.72], nodes: ['ref_capsule_mesh', 'head_height'] },
{ name: 'tree_gum_01', h: [4.0, 9.0],
nodes: ['trunk', 'canopy_01', 'canopy_02', 'canopy_03',
'branch_anchor_01', 'branch_anchor_02', 'branch_anchor_03'] },
{ name: 'tree_gum_02', h: [4.0, 9.0],
nodes: ['trunk', 'canopy_01', 'canopy_02', 'branch_anchor_01', 'branch_anchor_02'] },
{ name: 'fence_post', h: [1.8, 2.2], nodes: ['post'] },
{ name: 'fence_panel', h: [1.6, 2.0], nodes: ['palings', 'rails'] },
{ name: 'gate', h: [1.6, 2.0], nodes: ['gate_palings', 'gate_frame', 'hinges', 'hinge_axis'] },
{ name: 'house_yardside', h: [2.5, 3.5],
nodes: ['wall', 'door', 'window', 'roof', 'fascia', 'gutter',
'fascia_anchor_01', 'fascia_anchor_02', 'fascia_anchor_03'] },
{ name: 'shed_01', h: [1.9, 2.4], nodes: ['shell', 'roof', 'doors', 'door_anchor'] },
{ name: 'shed_table', h: [0.8, 1.0], nodes: ['table_top', 'table_frame', 'pickup_anchor'] },
{ name: 'garden_bed', h: [0.5, 1.1],
nodes: ['bed', 'soil', 'plants_full', 'plants_tattered', 'plants_dead'] },
{ name: 'sail_post', h: [3.8, 4.2],
nodes: ['footing', 'post', 'pad_eye', 'top_anchor', 'rake_pivot'] },
{ name: 'ladder_01', h: [2.8, 3.2], nodes: ['ladder', 'ladder_base', 'ladder_top'] },
{ name: 'shackle', h: [0.05, 0.15], nodes: ['bow', 'pin'] },
{ name: 'carabiner', h: [0.06, 0.15], nodes: ['body', 'gate'] },
{ name: 'turnbuckle', h: [0.12, 0.25], nodes: ['body', 'eye_a', 'eye_b'] },
{ name: 'tramp_01', h: [0.6, 1.0], nodes: ['mat', 'rim', 'pad', 'legs'], sub: 'debris/' },
];
function sizeOf(gltf) {
const s = new THREE.Vector3();
new THREE.Box3().setFromObject(gltf.scene).getSize(s);
return s;
}
/** @param {import('../testkit.js').Suite} t */
export default async function run(t) {
let GLTFLoader;
try {
({ GLTFLoader } = await import(LOADER_PATH));
} catch (err) {
t.skip('needs an importmap for the bare `three` specifier — see THREADS [E]. ' +
'Assets ARE verified meanwhile: tools/assetcheck/ (16/16 green in three.js r175)');
return;
}
const loader = new GLTFLoader();
const loaded = new Map();
const failed = new Map();
await Promise.all(ASSETS.map(async (a) => {
try { loaded.set(a.name, await loader.loadAsync(url(a))); }
catch (err) { failed.set(a.name, err?.message ?? String(err)); }
}));
t.test('every yard GLB loads through the vendored GLTFLoader', () => {
const lost = [...failed].map(([n, e]) => `${n} (${e})`).join('; ');
assert(failed.size === 0, `failed to load: ${lost}`);
});
// The anchor of the whole scale system. If this is wrong, every judgement
// made against the contact sheet was made against a lie.
t.test('ref_capsule is 1.70 m tall on +Y — the scale everything is judged against', () => {
const g = loaded.get('ref_capsule');
assert(g, 'ref_capsule did not load');
const s = sizeOf(g);
assert(Math.abs(s.y - 1.70) < 0.02, `capsule is ${s.y.toFixed(3)} m on Y, want 1.70`);
assert(s.x < 0.6 && s.z < 0.6,
`capsule is ${s.x.toFixed(2)} x ${s.z.toFixed(2)} in plan — height is not on +Y`);
});
for (const a of ASSETS) {
const gltf = loaded.get(a.name);
if (!gltf) continue; // already reported by the load test
const s = sizeOf(gltf);
t.test(`${a.name}: metre-scale, height on +Y`, () => {
assert(s.y >= a.h[0] && s.y <= a.h[1],
`${a.name} stands ${s.y.toFixed(3)} m, want ${a.h[0]}${a.h[1]} m ` +
`(box ${s.x.toFixed(2)} x ${s.y.toFixed(2)} x ${s.z.toFixed(2)})`);
});
t.test(`${a.name}: named nodes survive the export`, () => {
const names = new Set();
gltf.scene.traverse((o) => names.add(o.name));
const missing = a.nodes.filter((n) => !names.has(n));
assert(missing.length === 0,
`${a.name} lost ${missing.join(', ')} — other lanes query these by name`);
});
}
// Anchors are the actual product here: Lane B pins cloth corners to them and
// Lane A builds world.anchors from them. A surviving name isn't enough — the
// position has to be usable.
t.test('branch_anchor_01 resolves to a usable world position up the tree', () => {
const g = loaded.get('tree_gum_01');
assert(g, 'tree_gum_01 did not load');
const anchor = g.scene.getObjectByName('branch_anchor_01');
assert(anchor, 'branch_anchor_01 missing — glTF has no "empty", check it was not pruned');
g.scene.updateWorldMatrix(true, true);
const p = new THREE.Vector3().setFromMatrixPosition(anchor.matrixWorld);
assert(p.y > 1.0 && p.y < 6.0,
`anchor sits at y=${p.y.toFixed(2)} — want 16 m up the trunk`);
assert(Number.isFinite(p.x) && Number.isFinite(p.z), 'anchor world position is not finite');
});
// One GLB carries three wilt states as siblings; Lane A toggles .visible
// rather than reloading, so all three have to be present at once.
t.test('garden_bed carries all 3 damage states in one GLB', () => {
const g = loaded.get('garden_bed');
assert(g, 'garden_bed did not load');
for (const state of ['plants_full', 'plants_tattered', 'plants_dead']) {
assert(g.scene.getObjectByName(state), `${state} missing — Lane A toggles these by name`);
}
});
}

401
web/world/js/world.js Normal file
View File

@ -0,0 +1,401 @@
/**
* SHADES the yard. Lane A owns this file.
*
* M0 is deliberately graybox: every mesh here is a stand-in with the right
* dimensions and the right NAME, so Lane E's real GLBs drop into the same slots
* without anyone re-deriving positions. What is NOT placeholder, and what other
* lanes should build against, is the layout: anchor positions, ground heights,
* the garden rect and the sun direction.
*
* Geometry conventions: meters, +Y up, origin at yard centre on the ground.
* North (-Z) is the house edge. The yard is 30 (x) by 20 (z).
*/
import * as THREE from '../vendor/three.module.js';
import { YARD, createStubWind } from './contracts.js';
/**
* Ground height in meters at a world XZ. Pure and cheap this is called by the
* player every frame and by every piece of debris, so it stays closed-form
* rather than sampling a texture. Gentle by design (about ±0.3 m): enough that
* water has somewhere to go and the yard doesn't read as a table, not so much
* that it fights the rigging puzzle.
*
* @param {number} x
* @param {number} z
* @returns {number}
*/
export function heightAt(x, z) {
return (
0.18 * Math.sin(x * 0.21 + 1.3) * Math.cos(z * 0.27 - 0.4) +
0.09 * Math.sin(x * 0.53 - 2.1) * Math.sin(z * 0.41 + 0.8) -
0.06 * Math.cos(x * 0.11)
);
}
const GARDEN_BED = { x: 1, z: 2, w: 6, d: 4 };
// Sun: mid-afternoon, high and off the north-west shoulder. Elevation 55°.
// Stored as the direction from the GROUND toward the SUN (see contracts.js).
const SUN_ELEV = (55 * Math.PI) / 180;
const SUN_AZIM = (-125 * Math.PI) / 180;
const SUN_DIR = new THREE.Vector3(
Math.cos(SUN_ELEV) * Math.sin(SUN_AZIM),
Math.sin(SUN_ELEV),
Math.cos(SUN_ELEV) * Math.cos(SUN_AZIM),
).normalize();
const COLORS = {
sky: 0x9fc4dd,
grass: 0x4a7c3f,
soil: 0x6b4a2f,
plant: 0x7fce6a,
house: 0x8a8f96,
trim: 0x6e737a,
bark: 0x5a3d24,
leaf: 0x2f6b28,
steel: 0x9aa4ad,
timber: 0x7a6a4f,
};
// (kept explicit rather than clever — Lane E will replace these with materials
// baked into the GLBs, at which point this table shrinks to nothing.)
/**
* Build the yard.
*
* @param {THREE.Scene} scene
* @param {object} [opts]
* @param {import('./contracts.js').Wind} [opts.wind]
* Wind is injected because tree anchors sway with it, and sway is dynamic
* load the whole reason a tree anchor is scarier than a post. Defaults to
* the calm stub so world.js is usable headless before Lane C lands.
* @returns {import('./contracts.js').World}
*/
export function createWorld(scene, opts = {}) {
const wind = opts.wind ?? createStubWind({ calm: true });
const root = new THREE.Group();
root.name = 'yard';
scene.add(root);
/** @type {THREE.Object3D[]} */
const solids = [];
/** @type {import('./contracts.js').Anchor[]} */
const anchors = [];
/** @type {{group: THREE.Object3D, phase: number, base: THREE.Euler}[]} */
const canopies = [];
// --- sky & light -------------------------------------------------------
// Calm-day only. Lane C's skyfx.js takes over the sky and this becomes the
// "before" state the storm darkens away from.
scene.background = new THREE.Color(COLORS.sky);
scene.fog = new THREE.Fog(COLORS.sky, 34, 95);
// Sky fill carries more here than it would in most scenes. The sun sits in
// the NORTH (this is an Australian yard — "southerly change", gum trees), and
// the house is the yard's north edge, so the wall the player spends the whole
// game looking at is permanently backlit. That's correct, and it's also how
// a real south-facing rear wall looks — but the fascia line carries three of
// the seven anchors, so it has to stay readable in shadow rather than going
// to a void. Sky bounce is what does that in the real yard too.
const hemi = new THREE.HemisphereLight(0xbfd8ea, COLORS.grass, 1.8);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xfff2dc, 2.0);
sun.position.copy(SUN_DIR).multiplyScalar(40);
sun.target.position.set(0, 0, 0);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
// Frame the yard tightly — a loose shadow frustum is why sail shadows go
// soft and blocky, and the sail's shadow IS the product here.
const sc = sun.shadow.camera;
sc.left = -19; sc.right = 19; sc.top = 15; sc.bottom = -15;
sc.near = 1; sc.far = 90;
sun.shadow.bias = -0.0006;
sun.shadow.normalBias = 0.02;
scene.add(sun);
scene.add(sun.target);
// --- terrain -----------------------------------------------------------
const groundGeo = new THREE.PlaneGeometry(YARD.width, YARD.depth, 60, 40);
groundGeo.rotateX(-Math.PI / 2);
const gpos = groundGeo.attributes.position;
for (let i = 0; i < gpos.count; i++) {
gpos.setY(i, heightAt(gpos.getX(i), gpos.getZ(i)));
}
gpos.needsUpdate = true;
groundGeo.computeVertexNormals();
const ground = new THREE.Mesh(
groundGeo,
new THREE.MeshStandardMaterial({ color: COLORS.grass, roughness: 0.95 }),
);
ground.name = 'ground';
ground.receiveShadow = true;
root.add(ground);
// Deliberately NOT in `solids`: the ground is answered by heightAt(), which
// is exact and free. Raycasting it would be 4800 triangles a frame to learn
// something we already know in closed form.
// --- house (north edge) ------------------------------------------------
// Rear wall sits exactly on z = -10 so the fascia anchors have a round
// number to live on. Lane E's house_yardside.glb replaces this group and
// should keep fascia_anchor_* at these positions.
const house = new THREE.Group();
house.name = 'house_yardside';
const wall = new THREE.Mesh(
new THREE.BoxGeometry(16, 3.0, 6),
new THREE.MeshStandardMaterial({ color: COLORS.house, roughness: 0.85 }),
);
wall.position.set(0, 1.5, -13);
wall.castShadow = true;
wall.receiveShadow = true;
house.add(wall);
const roof = new THREE.Mesh(
new THREE.BoxGeometry(16.8, 0.22, 6.8),
new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.7 }),
);
roof.position.set(0, 3.1, -13);
roof.castShadow = true;
house.add(roof);
// The fascia line — the lie the player will be tempted by (DESIGN.md).
const fascia = new THREE.Mesh(
new THREE.BoxGeometry(16, 0.24, 0.12),
new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.6 }),
);
fascia.position.set(0, 2.72, -9.98);
house.add(fascia);
root.add(house);
solids.push(wall, roof);
for (const [i, x] of [-5, 0, 5].entries()) {
anchors.push(makeStaticAnchor(`h${i + 1}`, 'house', new THREE.Vector3(x, 2.6, -9.9)));
}
// --- trees -------------------------------------------------------------
// Two, on opposite shoulders, mirroring the prototype's tree placement.
// Canopies are separate named nodes so they can be swayed independently —
// Lane E's tree_gum_01.glb must keep `trunk` / `canopy_*` / `branch_anchor_*`.
const treeSpecs = [
{ id: 't1', x: -9, z: 2, phase: 0.7, trunkH: 4.2, anchorY: 3.4 },
{ id: 't2', x: 8, z: -2, phase: 2.9, trunkH: 3.8, anchorY: 3.1 },
];
for (const spec of treeSpecs) {
const y0 = heightAt(spec.x, spec.z);
const tree = new THREE.Group();
tree.name = `tree_${spec.id}`;
tree.position.set(spec.x, y0, spec.z);
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.18, 0.28, spec.trunkH, 8),
new THREE.MeshStandardMaterial({ color: COLORS.bark, roughness: 1 }),
);
trunk.name = 'trunk';
trunk.position.y = spec.trunkH / 2;
trunk.castShadow = true;
tree.add(trunk);
solids.push(trunk);
const canopy = new THREE.Group();
canopy.name = 'canopy';
canopy.position.y = spec.trunkH;
const blobMat = new THREE.MeshStandardMaterial({ color: COLORS.leaf, roughness: 1 });
for (const [j, b] of [
{ x: 0, y: 0.7, z: 0, r: 2.1 },
{ x: 1.1, y: 0.1, z: 0.5, r: 1.4 },
{ x: -0.9, y: 0.3, z: -0.6, r: 1.5 },
].entries()) {
const blob = new THREE.Mesh(new THREE.SphereGeometry(b.r, 12, 8), blobMat);
blob.name = `canopy_${j}`;
blob.position.set(b.x, b.y, b.z);
blob.castShadow = true;
canopy.add(blob);
}
tree.add(canopy);
canopies.push({ group: canopy, phase: spec.phase, base: canopy.rotation.clone() });
root.add(tree);
// The anchor is at a branch fork, not the canopy centre.
anchors.push(makeSwayAnchor(
spec.id,
new THREE.Vector3(spec.x, y0 + spec.anchorY, spec.z),
spec.phase,
wind,
));
}
// --- posts -------------------------------------------------------------
// Raked away from the yard centre, because that is the correct practice and
// the shape should teach it before any text does (DESIGN.md: "rake the post
// away from the load").
const postSpecs = [
{ id: 'p1', x: -6, z: 7, h: 4.0 },
{ id: 'p2', x: 5, z: 7.5, h: 4.0 },
];
const RAKE = (8 * Math.PI) / 180;
for (const spec of postSpecs) {
const y0 = heightAt(spec.x, spec.z);
// Lean away from the centre of the yard, in the XZ plane.
const away = new THREE.Vector2(spec.x, spec.z).normalize();
const top = new THREE.Vector3(
spec.x + Math.sin(RAKE) * spec.h * away.x,
y0 + Math.cos(RAKE) * spec.h,
spec.z + Math.sin(RAKE) * spec.h * away.y,
);
const post = new THREE.Mesh(
new THREE.CylinderGeometry(0.06, 0.08, spec.h, 8),
new THREE.MeshStandardMaterial({ color: COLORS.steel, roughness: 0.5, metalness: 0.6 }),
);
post.name = `sail_post_${spec.id}`;
post.position.set((spec.x + top.x) / 2, (y0 + top.y) / 2, (spec.z + top.z) / 2);
post.quaternion.setFromUnitVectors(
new THREE.Vector3(0, 1, 0),
top.clone().sub(new THREE.Vector3(spec.x, y0, spec.z)).normalize(),
);
post.castShadow = true;
root.add(post);
solids.push(post);
const footing = new THREE.Mesh(
new THREE.CylinderGeometry(0.22, 0.26, 0.18, 10),
new THREE.MeshStandardMaterial({ color: 0x9c9c96, roughness: 0.95 }),
);
footing.position.set(spec.x, y0 + 0.06, spec.z);
footing.receiveShadow = true;
root.add(footing);
anchors.push(makeStaticAnchor(spec.id, 'post', top));
}
// --- garden bed (the thing you are protecting) -------------------------
const bed = new THREE.Group();
bed.name = 'garden_bed';
const bedY = heightAt(GARDEN_BED.x, GARDEN_BED.z);
bed.position.set(GARDEN_BED.x, bedY, GARDEN_BED.z);
const soil = new THREE.Mesh(
new THREE.BoxGeometry(GARDEN_BED.w, 0.3, GARDEN_BED.d),
new THREE.MeshStandardMaterial({ color: COLORS.soil, roughness: 1 }),
);
soil.position.y = 0.15;
soil.receiveShadow = true;
bed.add(soil);
const plantMat = new THREE.MeshStandardMaterial({ color: COLORS.plant, roughness: 0.9 });
for (let i = 0; i < 6; i++) {
for (let j = 0; j < 4; j++) {
const plant = new THREE.Mesh(new THREE.SphereGeometry(0.28, 8, 6), plantMat);
plant.position.set(
(-0.5 + (i + 0.5) / 6) * GARDEN_BED.w,
0.42,
(-0.5 + (j + 0.5) / 4) * GARDEN_BED.d,
);
plant.castShadow = true;
plant.receiveShadow = true;
bed.add(plant);
}
}
root.add(bed);
// --- boundary fence ----------------------------------------------------
// East, south and west only: the house is the north boundary.
const fence = new THREE.Group();
fence.name = 'fence';
const railMat = new THREE.MeshStandardMaterial({ color: 0x7a6a4f, roughness: 1 });
const hx = YARD.width / 2, hz = YARD.depth / 2;
const runs = [
{ from: [-hx, hz], to: [hx, hz] }, // south
{ from: [-hx, -hz], to: [-hx, hz] }, // west
{ from: [hx, -hz], to: [hx, hz] }, // east
];
for (const run of runs) {
const [x0, z0] = run.from, [x1, z1] = run.to;
const len = Math.hypot(x1 - x0, z1 - z0);
const n = Math.round(len / 2.5);
for (let i = 0; i <= n; i++) {
const f = i / n;
const x = x0 + (x1 - x0) * f, z = z0 + (z1 - z0) * f;
const p = new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.6, 0.1), railMat);
p.position.set(x, heightAt(x, z) + 0.8, z);
p.castShadow = true;
fence.add(p);
}
for (const ry of [0.6, 1.35]) {
const rail = new THREE.Mesh(new THREE.BoxGeometry(len, 0.12, 0.04), railMat);
rail.position.set((x0 + x1) / 2, heightAt((x0 + x1) / 2, (z0 + z1) / 2) + ry, (z0 + z1) / 2);
rail.rotation.y = Math.atan2(-(z1 - z0), x1 - x0);
rail.castShadow = true;
fence.add(rail);
}
}
root.add(fence);
solids.push(fence);
// --- the world object --------------------------------------------------
return {
anchors,
heightAt,
gardenBed: GARDEN_BED,
sunDir: SUN_DIR.clone(),
solids,
root,
sun,
/** @param {string} id */
anchor(id) {
return anchors.find((a) => a.id === id) ?? null;
},
update(dt, t) {
// Canopies lean with the wind they actually stand in — this is the tell
// the player reads a gust front from, a beat before it reaches the sail.
for (const c of canopies) {
const w = wind.sample(c.group.getWorldPosition(_worldPos), t);
const speed = w.length();
const lean = Math.min(0.22, speed * 0.007);
const flutter = 0.55 + 0.45 * Math.sin(t * 2.3 + c.phase);
c.group.rotation.z = c.base.z - Math.cos(Math.atan2(w.z, w.x)) * lean * flutter;
c.group.rotation.x = c.base.x + Math.sin(Math.atan2(w.z, w.x)) * lean * flutter;
}
},
};
}
const _worldPos = new THREE.Vector3();
/** @returns {import('./contracts.js').Anchor} */
function makeStaticAnchor(id, type, pos) {
const p = pos.clone();
return { id, type, pos: p, sway: () => p };
}
/**
* A tree anchor. Wanders with the wind it stands in, which is exactly why it is
* the dangerous choice: the sway is dynamic load the rig has to eat, and a
* drum-tight rig on a swaying tree snaps turnbuckles (DESIGN.md).
*
* @returns {import('./contracts.js').Anchor}
*/
function makeSwayAnchor(id, pos, phase, wind) {
const p = pos.clone();
const scratch = new THREE.Vector3(); // per-anchor, so two anchors never alias
return {
id,
type: 'tree',
pos: p,
sway(t) {
const speed = wind.sample(p, t).length();
const amp = Math.min(0.35, speed * 0.012);
const s = Math.sin(t * 1.9 + phase);
return scratch.set(
p.x + s * amp,
p.y - Math.abs(s) * amp * 0.25,
p.z + Math.cos(t * 1.3 + phase) * amp * 0.5,
);
},
};
}

View File

@ -0,0 +1,13 @@
# Lane D runtime models (copies — see PLAN3D §0 "asset copies rule")
| File | Source (canonical) | Notes |
|---|---|---|
| `player_01.glb` | `90sDJsim/web/world/models/peds/man_casual_01.glb` | **Byte copy, never edited.** Head bone 1.75 m, already metre-scale. |
| `player_anims.glb` | built by `tools/character/build_player_anims.py` on the M1 Ultra | Anim-only carrier: Idle/Walk/Run/Falling/CrouchToStand/Reaction. No mesh, no skin. |
Rebuild the clips: see the header of `tools/character/build_player_anims.py`.
**Do not round-trip `player_01.glb` through Blender.** The peds encode metre scale as a node
scale of 0.01 on `mixamorig*:Hips` with children in centimetres; Blender bones have no rest
scale, so the glTF importer drops it and the skeleton explodes on import (`LeftToe_End` lands
96 m below the floor). Clips are carried beside the ped instead, never merged into it.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

97
web/world/selftest.html Normal file
View File

@ -0,0 +1,97 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SHADES — selftest</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0; padding: 22px 20px 60px;
background: #10161b; color: #dde5ea;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
}
h1 { font-size: 15px; letter-spacing: .16em; margin: 0 0 4px; color: #fff; }
.sub { color: #7f8f9b; margin-bottom: 18px; }
#summary { font-size: 15px; font-weight: 700; padding: 10px 14px; border-radius: 6px; margin-bottom: 18px; }
.ok { background: #12321c; color: #7fce6a; border: 1px solid #2c6b3c; }
.bad { background: #3a1618; color: #ff8f86; border: 1px solid #7d2b2b; }
.lane { margin: 16px 0 6px; color: #7ee0ff; letter-spacing: .1em; }
table { border-collapse: collapse; width: 100%; max-width: 1000px; }
td { padding: 3px 10px 3px 0; vertical-align: top; }
td.s { width: 58px; font-weight: 700; }
td.ms { width: 66px; color: #66757f; text-align: right; }
.pass { color: #7fce6a; } .fail { color: #ff6b6b; } .skip { color: #7f8f9b; }
.err { color: #ffb1ab; padding-left: 68px; display: block; }
tr.f td { background: #2a1214; }
</style>
</head>
<body>
<h1>SHADES SELFTEST</h1>
<div class="sub">
Fixed-dt only — no requestAnimationFrame, so this stays honest in a background tab.
Full report is also on the console as JSON.
</div>
<div id="summary">running…</div>
<div id="out"></div>
<script type="module">
import { runAll } from './js/testkit.js';
// One import per lane. Each lane owns js/tests/<letter>.test.js and nobody has
// to touch this file — that's the whole point, it keeps selftest.html out of
// the merge path. (Lane A addition to PLAN3D §3; logged in THREADS.md.)
const lanes = [];
for (const [letter, path] of [
['A', './js/tests/a.test.js'],
['B', './js/tests/b.test.js'],
['C', './js/tests/c.test.js'],
['D', './js/tests/d.test.js'],
['E', './js/tests/e.test.js'],
]) {
try {
const mod = await import(path);
lanes.push([letter, mod.default]);
} catch (err) {
// A lane whose module doesn't parse or whose dependency isn't landed yet
// must not blank the report for everyone else.
lanes.push([letter, () => { throw new Error(`import failed: ${err.message}`); }]);
}
}
const report = await runAll(lanes);
const summary = document.getElementById('summary');
summary.className = report.ok ? 'ok' : 'bad';
summary.textContent = report.ok
? `PASS — ${report.pass} passed, ${report.skip} skipped`
: `FAIL — ${report.fail} failed, ${report.pass} passed, ${report.skip} skipped`;
const out = document.getElementById('out');
for (const letter of ['A', 'B', 'C', 'D', 'E']) {
const rows = report.results.filter((r) => r.lane === letter);
if (!rows.length) continue;
const h = document.createElement('div');
h.className = 'lane';
h.textContent = `LANE ${letter}`;
out.append(h);
const table = document.createElement('table');
for (const r of rows) {
const tr = table.insertRow();
if (r.status === 'fail') tr.className = 'f';
const s = tr.insertCell(); s.className = `s ${r.status}`; s.textContent = r.status.toUpperCase();
const l = tr.insertCell(); l.textContent = r.label;
if (r.err) { const e = document.createElement('span'); e.className = 'err'; e.textContent = `↳ ${r.err}`; l.append(e); }
const m = tr.insertCell(); m.className = 'ms'; m.textContent = r.ms >= 0.05 ? `${r.ms.toFixed(1)}ms` : '';
}
out.append(table);
}
console.log(JSON.stringify(report, null, 2));
// Scrapeable by anything that wants a verdict without parsing the DOM.
globalThis.SHADES_SELFTEST = report;
document.title = `SHADES selftest — ${report.ok ? 'PASS' : 'FAIL'}`;
</script>
</body>
</html>

View File

@ -0,0 +1,270 @@
import {
Controls,
Euler,
Vector3
} from 'three';
const _euler = new Euler( 0, 0, 0, 'YXZ' );
const _vector = new Vector3();
/**
* Fires when the user moves the mouse.
*
* @event PointerLockControls#change
* @type {Object}
*/
const _changeEvent = { type: 'change' };
/**
* Fires when the pointer lock status is "locked" (in other words: the mouse is captured).
*
* @event PointerLockControls#lock
* @type {Object}
*/
const _lockEvent = { type: 'lock' };
/**
* Fires when the pointer lock status is "unlocked" (in other words: the mouse is not captured anymore).
*
* @event PointerLockControls#unlock
* @type {Object}
*/
const _unlockEvent = { type: 'unlock' };
const _PI_2 = Math.PI / 2;
/**
* The implementation of this class is based on the [Pointer Lock API]{@link https://developer.mozilla.org/en-US/docs/Web/API/Pointer_Lock_API}.
* `PointerLockControls` is a perfect choice for first person 3D games.
*
* ```js
* const controls = new PointerLockControls( camera, document.body );
*
* // add event listener to show/hide a UI (e.g. the game's menu)
* controls.addEventListener( 'lock', function () {
*
* menu.style.display = 'none';
*
* } );
*
* controls.addEventListener( 'unlock', function () {
*
* menu.style.display = 'block';
*
* } );
* ```
*
* @augments Controls
*/
class PointerLockControls extends Controls {
/**
* Constructs a new controls instance.
*
* @param {Camera} camera - The camera that is managed by the controls.
* @param {?HTMLDOMElement} domElement - The HTML element used for event listeners.
*/
constructor( camera, domElement = null ) {
super( camera, domElement );
/**
* Whether the controls are locked or not.
*
* @type {boolean}
* @readonly
* @default false
*/
this.isLocked = false;
/**
* Camera pitch, lower limit. Range is '[0, Math.PI]' in radians.
*
* @type {number}
* @default 0
*/
this.minPolarAngle = 0;
/**
* Camera pitch, upper limit. Range is '[0, Math.PI]' in radians.
*
* @type {number}
* @default Math.PI
*/
this.maxPolarAngle = Math.PI;
/**
* Multiplier for how much the pointer movement influences the camera rotation.
*
* @type {number}
* @default 1
*/
this.pointerSpeed = 1.0;
// event listeners
this._onMouseMove = onMouseMove.bind( this );
this._onPointerlockChange = onPointerlockChange.bind( this );
this._onPointerlockError = onPointerlockError.bind( this );
if ( this.domElement !== null ) {
this.connect( this.domElement );
}
}
connect( element ) {
super.connect( element );
this.domElement.ownerDocument.addEventListener( 'mousemove', this._onMouseMove );
this.domElement.ownerDocument.addEventListener( 'pointerlockchange', this._onPointerlockChange );
this.domElement.ownerDocument.addEventListener( 'pointerlockerror', this._onPointerlockError );
}
disconnect() {
this.domElement.ownerDocument.removeEventListener( 'mousemove', this._onMouseMove );
this.domElement.ownerDocument.removeEventListener( 'pointerlockchange', this._onPointerlockChange );
this.domElement.ownerDocument.removeEventListener( 'pointerlockerror', this._onPointerlockError );
}
dispose() {
this.disconnect();
}
getObject() {
console.warn( 'THREE.PointerLockControls: getObject() has been deprecated. Use controls.object instead.' ); // @deprecated r169
return this.object;
}
/**
* Returns the look direction of the camera.
*
* @param {Vector3} v - The target vector that is used to store the method's result.
* @return {Vector3} The normalized direction vector.
*/
getDirection( v ) {
return v.set( 0, 0, - 1 ).applyQuaternion( this.object.quaternion );
}
/**
* Moves the camera forward parallel to the xz-plane. Assumes camera.up is y-up.
*
* @param {number} distance - The signed distance.
*/
moveForward( distance ) {
if ( this.enabled === false ) return;
// move forward parallel to the xz-plane
// assumes camera.up is y-up
const camera = this.object;
_vector.setFromMatrixColumn( camera.matrix, 0 );
_vector.crossVectors( camera.up, _vector );
camera.position.addScaledVector( _vector, distance );
}
/**
* Moves the camera sidewards parallel to the xz-plane.
*
* @param {number} distance - The signed distance.
*/
moveRight( distance ) {
if ( this.enabled === false ) return;
const camera = this.object;
_vector.setFromMatrixColumn( camera.matrix, 0 );
camera.position.addScaledVector( _vector, distance );
}
/**
* Activates the pointer lock.
*
* @param {boolean} [unadjustedMovement=false] - Disables OS-level adjustment for mouse acceleration, and accesses raw mouse input instead.
* Setting it to true will disable mouse acceleration.
*/
lock( unadjustedMovement = false ) {
this.domElement.requestPointerLock( {
unadjustedMovement
} );
}
/**
* Exits the pointer lock.
*/
unlock() {
this.domElement.ownerDocument.exitPointerLock();
}
}
// event listeners
function onMouseMove( event ) {
if ( this.enabled === false || this.isLocked === false ) return;
const camera = this.object;
_euler.setFromQuaternion( camera.quaternion );
_euler.y -= event.movementX * 0.002 * this.pointerSpeed;
_euler.x -= event.movementY * 0.002 * this.pointerSpeed;
_euler.x = Math.max( _PI_2 - this.maxPolarAngle, Math.min( _PI_2 - this.minPolarAngle, _euler.x ) );
camera.quaternion.setFromEuler( _euler );
this.dispatchEvent( _changeEvent );
}
function onPointerlockChange() {
if ( this.domElement.ownerDocument.pointerLockElement === this.domElement ) {
this.dispatchEvent( _lockEvent );
this.isLocked = true;
} else {
this.dispatchEvent( _unlockEvent );
this.isLocked = false;
}
}
function onPointerlockError() {
console.error( 'THREE.PointerLockControls: Unable to use Pointer Lock API' );
}
export { PointerLockControls };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,361 @@
import {
Clock,
HalfFloatType,
NoBlending,
Vector2,
WebGLRenderTarget
} from 'three';
import { CopyShader } from '../shaders/CopyShader.js';
import { ShaderPass } from './ShaderPass.js';
import { ClearMaskPass, MaskPass } from './MaskPass.js';
/**
* Used to implement post-processing effects in three.js.
* The class manages a chain of post-processing passes to produce the final visual result.
* Post-processing passes are executed in order of their addition/insertion.
* The last pass is automatically rendered to screen.
*
* This module can only be used with {@link WebGLRenderer}.
*
* ```js
* const composer = new EffectComposer( renderer );
*
* // adding some passes
* const renderPass = new RenderPass( scene, camera );
* composer.addPass( renderPass );
*
* const glitchPass = new GlitchPass();
* composer.addPass( glitchPass );
*
* const outputPass = new OutputPass()
* composer.addPass( outputPass );
*
* function animate() {
*
* composer.render(); // instead of renderer.render()
*
* }
* ```
*/
class EffectComposer {
/**
* Constructs a new effect composer.
*
* @param {WebGLRenderer} renderer - The renderer.
* @param {WebGLRenderTarget} [renderTarget] - This render target and a clone will
* be used as the internal read and write buffers. If not given, the composer creates
* the buffers automatically.
*/
constructor( renderer, renderTarget ) {
/**
* The renderer.
*
* @type {WebGLRenderer}
*/
this.renderer = renderer;
this._pixelRatio = renderer.getPixelRatio();
if ( renderTarget === undefined ) {
const size = renderer.getSize( new Vector2() );
this._width = size.width;
this._height = size.height;
renderTarget = new WebGLRenderTarget( this._width * this._pixelRatio, this._height * this._pixelRatio, { type: HalfFloatType } );
renderTarget.texture.name = 'EffectComposer.rt1';
} else {
this._width = renderTarget.width;
this._height = renderTarget.height;
}
this.renderTarget1 = renderTarget;
this.renderTarget2 = renderTarget.clone();
this.renderTarget2.texture.name = 'EffectComposer.rt2';
/**
* A reference to the internal write buffer. Passes usually write
* their result into this buffer.
*
* @type {WebGLRenderTarget}
*/
this.writeBuffer = this.renderTarget1;
/**
* A reference to the internal read buffer. Passes usually read
* the previous render result from this buffer.
*
* @type {WebGLRenderTarget}
*/
this.readBuffer = this.renderTarget2;
/**
* Whether the final pass is rendered to the screen (default framebuffer) or not.
*
* @type {boolean}
* @default true
*/
this.renderToScreen = true;
/**
* An array representing the (ordered) chain of post-processing passes.
*
* @type {Array<Pass>}
*/
this.passes = [];
/**
* A copy pass used for internal swap operations.
*
* @private
* @type {ShaderPass}
*/
this.copyPass = new ShaderPass( CopyShader );
this.copyPass.material.blending = NoBlending;
/**
* The intenral clock for managing time data.
*
* @private
* @type {Clock}
*/
this.clock = new Clock();
}
/**
* Swaps the internal read/write buffers.
*/
swapBuffers() {
const tmp = this.readBuffer;
this.readBuffer = this.writeBuffer;
this.writeBuffer = tmp;
}
/**
* Adds the given pass to the pass chain.
*
* @param {Pass} pass - The pass to add.
*/
addPass( pass ) {
this.passes.push( pass );
pass.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
}
/**
* Inserts the given pass at a given index.
*
* @param {Pass} pass - The pass to insert.
* @param {number} index - The index into the pass chain.
*/
insertPass( pass, index ) {
this.passes.splice( index, 0, pass );
pass.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
}
/**
* Removes the given pass from the pass chain.
*
* @param {Pass} pass - The pass to remove.
*/
removePass( pass ) {
const index = this.passes.indexOf( pass );
if ( index !== - 1 ) {
this.passes.splice( index, 1 );
}
}
/**
* Returns `true` if the pass for the given index is the last enabled pass in the pass chain.
*
* @param {number} passIndex - The pass index.
* @return {boolean} Whether the the pass for the given index is the last pass in the pass chain.
*/
isLastEnabledPass( passIndex ) {
for ( let i = passIndex + 1; i < this.passes.length; i ++ ) {
if ( this.passes[ i ].enabled ) {
return false;
}
}
return true;
}
/**
* Executes all enabled post-processing passes in order to produce the final frame.
*
* @param {number} deltaTime - The delta time in seconds. If not given, the composer computes
* its own time delta value.
*/
render( deltaTime ) {
// deltaTime value is in seconds
if ( deltaTime === undefined ) {
deltaTime = this.clock.getDelta();
}
const currentRenderTarget = this.renderer.getRenderTarget();
let maskActive = false;
for ( let i = 0, il = this.passes.length; i < il; i ++ ) {
const pass = this.passes[ i ];
if ( pass.enabled === false ) continue;
pass.renderToScreen = ( this.renderToScreen && this.isLastEnabledPass( i ) );
pass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime, maskActive );
if ( pass.needsSwap ) {
if ( maskActive ) {
const context = this.renderer.getContext();
const stencil = this.renderer.state.buffers.stencil;
//context.stencilFunc( context.NOTEQUAL, 1, 0xffffffff );
stencil.setFunc( context.NOTEQUAL, 1, 0xffffffff );
this.copyPass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime );
//context.stencilFunc( context.EQUAL, 1, 0xffffffff );
stencil.setFunc( context.EQUAL, 1, 0xffffffff );
}
this.swapBuffers();
}
if ( MaskPass !== undefined ) {
if ( pass instanceof MaskPass ) {
maskActive = true;
} else if ( pass instanceof ClearMaskPass ) {
maskActive = false;
}
}
}
this.renderer.setRenderTarget( currentRenderTarget );
}
/**
* Resets the internal state of the EffectComposer.
*
* @param {WebGLRenderTarget} [renderTarget] - This render target has the same purpose like
* the one from the constructor. If set, it is used to setup the read and write buffers.
*/
reset( renderTarget ) {
if ( renderTarget === undefined ) {
const size = this.renderer.getSize( new Vector2() );
this._pixelRatio = this.renderer.getPixelRatio();
this._width = size.width;
this._height = size.height;
renderTarget = this.renderTarget1.clone();
renderTarget.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
}
this.renderTarget1.dispose();
this.renderTarget2.dispose();
this.renderTarget1 = renderTarget;
this.renderTarget2 = renderTarget.clone();
this.writeBuffer = this.renderTarget1;
this.readBuffer = this.renderTarget2;
}
/**
* Resizes the internal read and write buffers as well as all passes. Similar to {@link WebGLRenderer#setSize},
* this method honors the current pixel ration.
*
* @param {number} width - The width in logical pixels.
* @param {number} height - The height in logical pixels.
*/
setSize( width, height ) {
this._width = width;
this._height = height;
const effectiveWidth = this._width * this._pixelRatio;
const effectiveHeight = this._height * this._pixelRatio;
this.renderTarget1.setSize( effectiveWidth, effectiveHeight );
this.renderTarget2.setSize( effectiveWidth, effectiveHeight );
for ( let i = 0; i < this.passes.length; i ++ ) {
this.passes[ i ].setSize( effectiveWidth, effectiveHeight );
}
}
/**
* Sets device pixel ratio. This is usually used for HiDPI device to prevent blurring output.
* Setting the pixel ratio will automatically resize the composer.
*
* @param {number} pixelRatio - The pixel ratio to set.
*/
setPixelRatio( pixelRatio ) {
this._pixelRatio = pixelRatio;
this.setSize( this._width, this._height );
}
/**
* Frees the GPU-related resources allocated by this instance. Call this
* method whenever the composer is no longer used in your app.
*/
dispose() {
this.renderTarget1.dispose();
this.renderTarget2.dispose();
this.copyPass.dispose();
}
}
export { EffectComposer };

View File

@ -0,0 +1,194 @@
import { Pass } from './Pass.js';
/**
* This pass can be used to define a mask during post processing.
* Meaning only areas of subsequent post processing are affected
* which lie in the masking area of this pass. Internally, the masking
* is implemented with the stencil buffer.
*
* ```js
* const maskPass = new MaskPass( scene, camera );
* composer.addPass( maskPass );
* ```
*
* @augments Pass
*/
class MaskPass extends Pass {
/**
* Constructs a new mask pass.
*
* @param {Scene} scene - The 3D objects in this scene will define the mask.
* @param {Camera} camera - The camera.
*/
constructor( scene, camera ) {
super();
/**
* The scene that defines the mask.
*
* @type {Scene}
*/
this.scene = scene;
/**
* The camera.
*
* @type {Camera}
*/
this.camera = camera;
/**
* Overwritten to perform a clear operation by default.
*
* @type {boolean}
* @default true
*/
this.clear = true;
/**
* Overwritten to disable the swap.
*
* @type {boolean}
* @default false
*/
this.needsSwap = false;
/**
* Whether to inverse the mask or not.
*
* @type {boolean}
* @default false
*/
this.inverse = false;
}
/**
* Performs a mask pass with the configured scene and camera.
*
* @param {WebGLRenderer} renderer - The renderer.
* @param {WebGLRenderTarget} writeBuffer - The write buffer. This buffer is intended as the rendering
* destination for the pass.
* @param {WebGLRenderTarget} readBuffer - The read buffer. The pass can access the result from the
* previous pass from this buffer.
* @param {number} deltaTime - The delta time in seconds.
* @param {boolean} maskActive - Whether masking is active or not.
*/
render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
const context = renderer.getContext();
const state = renderer.state;
// don't update color or depth
state.buffers.color.setMask( false );
state.buffers.depth.setMask( false );
// lock buffers
state.buffers.color.setLocked( true );
state.buffers.depth.setLocked( true );
// set up stencil
let writeValue, clearValue;
if ( this.inverse ) {
writeValue = 0;
clearValue = 1;
} else {
writeValue = 1;
clearValue = 0;
}
state.buffers.stencil.setTest( true );
state.buffers.stencil.setOp( context.REPLACE, context.REPLACE, context.REPLACE );
state.buffers.stencil.setFunc( context.ALWAYS, writeValue, 0xffffffff );
state.buffers.stencil.setClear( clearValue );
state.buffers.stencil.setLocked( true );
// draw into the stencil buffer
renderer.setRenderTarget( readBuffer );
if ( this.clear ) renderer.clear();
renderer.render( this.scene, this.camera );
renderer.setRenderTarget( writeBuffer );
if ( this.clear ) renderer.clear();
renderer.render( this.scene, this.camera );
// unlock color and depth buffer and make them writable for subsequent rendering/clearing
state.buffers.color.setLocked( false );
state.buffers.depth.setLocked( false );
state.buffers.color.setMask( true );
state.buffers.depth.setMask( true );
// only render where stencil is set to 1
state.buffers.stencil.setLocked( false );
state.buffers.stencil.setFunc( context.EQUAL, 1, 0xffffffff ); // draw if == 1
state.buffers.stencil.setOp( context.KEEP, context.KEEP, context.KEEP );
state.buffers.stencil.setLocked( true );
}
}
/**
* This pass can be used to clear a mask previously defined with {@link MaskPass}.
*
* ```js
* const clearPass = new ClearMaskPass();
* composer.addPass( clearPass );
* ```
*
* @augments Pass
*/
class ClearMaskPass extends Pass {
/**
* Constructs a new clear mask pass.
*/
constructor() {
super();
/**
* Overwritten to disable the swap.
*
* @type {boolean}
* @default false
*/
this.needsSwap = false;
}
/**
* Performs the clear of the currently defined mask.
*
* @param {WebGLRenderer} renderer - The renderer.
* @param {WebGLRenderTarget} writeBuffer - The write buffer. This buffer is intended as the rendering
* destination for the pass.
* @param {WebGLRenderTarget} readBuffer - The read buffer. The pass can access the result from the
* previous pass from this buffer.
* @param {number} deltaTime - The delta time in seconds.
* @param {boolean} maskActive - Whether masking is active or not.
*/
render( renderer /*, writeBuffer, readBuffer, deltaTime, maskActive */ ) {
renderer.state.buffers.stencil.setLocked( false );
renderer.state.buffers.stencil.setTest( false );
}
}
export { MaskPass, ClearMaskPass };

View File

@ -0,0 +1,138 @@
import {
ColorManagement,
RawShaderMaterial,
UniformsUtils,
LinearToneMapping,
ReinhardToneMapping,
CineonToneMapping,
AgXToneMapping,
ACESFilmicToneMapping,
NeutralToneMapping,
CustomToneMapping,
SRGBTransfer
} from 'three';
import { Pass, FullScreenQuad } from './Pass.js';
import { OutputShader } from '../shaders/OutputShader.js';
/**
* This pass is responsible for including tone mapping and color space conversion
* into your pass chain. In most cases, this pass should be included at the end
* of each pass chain. If a pass requires sRGB input (e.g. like FXAA), the pass
* must follow `OutputPass` in the pass chain.
*
* The tone mapping and color space settings are extracted from the renderer.
*
* ```js
* const outputPass = new OutputPass();
* composer.addPass( outputPass );
* ```
*
* @augments Pass
*/
class OutputPass extends Pass {
/**
* Constructs a new output pass.
*/
constructor() {
super();
/**
* The pass uniforms.
*
* @type {Object}
*/
this.uniforms = UniformsUtils.clone( OutputShader.uniforms );
/**
* The pass material.
*
* @type {RawShaderMaterial}
*/
this.material = new RawShaderMaterial( {
name: OutputShader.name,
uniforms: this.uniforms,
vertexShader: OutputShader.vertexShader,
fragmentShader: OutputShader.fragmentShader
} );
// internals
this._fsQuad = new FullScreenQuad( this.material );
this._outputColorSpace = null;
this._toneMapping = null;
}
/**
* Performs the output pass.
*
* @param {WebGLRenderer} renderer - The renderer.
* @param {WebGLRenderTarget} writeBuffer - The write buffer. This buffer is intended as the rendering
* destination for the pass.
* @param {WebGLRenderTarget} readBuffer - The read buffer. The pass can access the result from the
* previous pass from this buffer.
* @param {number} deltaTime - The delta time in seconds.
* @param {boolean} maskActive - Whether masking is active or not.
*/
render( renderer, writeBuffer, readBuffer/*, deltaTime, maskActive */ ) {
this.uniforms[ 'tDiffuse' ].value = readBuffer.texture;
this.uniforms[ 'toneMappingExposure' ].value = renderer.toneMappingExposure;
// rebuild defines if required
if ( this._outputColorSpace !== renderer.outputColorSpace || this._toneMapping !== renderer.toneMapping ) {
this._outputColorSpace = renderer.outputColorSpace;
this._toneMapping = renderer.toneMapping;
this.material.defines = {};
if ( ColorManagement.getTransfer( this._outputColorSpace ) === SRGBTransfer ) this.material.defines.SRGB_TRANSFER = '';
if ( this._toneMapping === LinearToneMapping ) this.material.defines.LINEAR_TONE_MAPPING = '';
else if ( this._toneMapping === ReinhardToneMapping ) this.material.defines.REINHARD_TONE_MAPPING = '';
else if ( this._toneMapping === CineonToneMapping ) this.material.defines.CINEON_TONE_MAPPING = '';
else if ( this._toneMapping === ACESFilmicToneMapping ) this.material.defines.ACES_FILMIC_TONE_MAPPING = '';
else if ( this._toneMapping === AgXToneMapping ) this.material.defines.AGX_TONE_MAPPING = '';
else if ( this._toneMapping === NeutralToneMapping ) this.material.defines.NEUTRAL_TONE_MAPPING = '';
else if ( this._toneMapping === CustomToneMapping ) this.material.defines.CUSTOM_TONE_MAPPING = '';
this.material.needsUpdate = true;
}
//
if ( this.renderToScreen === true ) {
renderer.setRenderTarget( null );
this._fsQuad.render( renderer );
} else {
renderer.setRenderTarget( writeBuffer );
if ( this.clear ) renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
this._fsQuad.render( renderer );
}
}
/**
* Frees the GPU-related resources allocated by this instance. Call this
* method whenever the pass is no longer used in your app.
*/
dispose() {
this.material.dispose();
this._fsQuad.dispose();
}
}
export { OutputPass };

View File

@ -0,0 +1,189 @@
import {
BufferGeometry,
Float32BufferAttribute,
OrthographicCamera,
Mesh
} from 'three';
/**
* Abstract base class for all post processing passes.
*
* This module is only relevant for post processing with {@link WebGLRenderer}.
*
* @abstract
*/
class Pass {
/**
* Constructs a new pass.
*/
constructor() {
/**
* This flag can be used for type testing.
*
* @type {boolean}
* @readonly
* @default true
*/
this.isPass = true;
/**
* If set to `true`, the pass is processed by the composer.
*
* @type {boolean}
* @default true
*/
this.enabled = true;
/**
* If set to `true`, the pass indicates to swap read and write buffer after rendering.
*
* @type {boolean}
* @default true
*/
this.needsSwap = true;
/**
* If set to `true`, the pass clears its buffer before rendering
*
* @type {boolean}
* @default false
*/
this.clear = false;
/**
* If set to `true`, the result of the pass is rendered to screen. The last pass in the composers
* pass chain gets automatically rendered to screen, no matter how this property is configured.
*
* @type {boolean}
* @default false
*/
this.renderToScreen = false;
}
/**
* Sets the size of the pass.
*
* @abstract
* @param {number} width - The width to set.
* @param {number} height - The width to set.
*/
setSize( /* width, height */ ) {}
/**
* This method holds the render logic of a pass. It must be implemented in all derived classes.
*
* @abstract
* @param {WebGLRenderer} renderer - The renderer.
* @param {WebGLRenderTarget} writeBuffer - The write buffer. This buffer is intended as the rendering
* destination for the pass.
* @param {WebGLRenderTarget} readBuffer - The read buffer. The pass can access the result from the
* previous pass from this buffer.
* @param {number} deltaTime - The delta time in seconds.
* @param {boolean} maskActive - Whether masking is active or not.
*/
render( /* renderer, writeBuffer, readBuffer, deltaTime, maskActive */ ) {
console.error( 'THREE.Pass: .render() must be implemented in derived pass.' );
}
/**
* Frees the GPU-related resources allocated by this instance. Call this
* method whenever the pass is no longer used in your app.
*
* @abstract
*/
dispose() {}
}
// Helper for passes that need to fill the viewport with a single quad.
const _camera = new OrthographicCamera( - 1, 1, 1, - 1, 0, 1 );
// https://github.com/mrdoob/three.js/pull/21358
class FullscreenTriangleGeometry extends BufferGeometry {
constructor() {
super();
this.setAttribute( 'position', new Float32BufferAttribute( [ - 1, 3, 0, - 1, - 1, 0, 3, - 1, 0 ], 3 ) );
this.setAttribute( 'uv', new Float32BufferAttribute( [ 0, 2, 0, 0, 2, 0 ], 2 ) );
}
}
const _geometry = new FullscreenTriangleGeometry();
/**
* This module is a helper for passes which need to render a full
* screen effect which is quite common in context of post processing.
*
* The intended usage is to reuse a single full screen quad for rendering
* subsequent passes by just reassigning the `material` reference.
*
* This module can only be used with {@link WebGLRenderer}.
*
* @augments Mesh
*/
class FullScreenQuad {
/**
* Constructs a new full screen quad.
*
* @param {?Material} material - The material to render te full screen quad with.
*/
constructor( material ) {
this._mesh = new Mesh( _geometry, material );
}
/**
* Frees the GPU-related resources allocated by this instance. Call this
* method whenever the instance is no longer used in your app.
*/
dispose() {
this._mesh.geometry.dispose();
}
/**
* Renders the full screen quad.
*
* @param {WebGLRenderer} renderer - The renderer.
*/
render( renderer ) {
renderer.render( this._mesh, _camera );
}
/**
* The quad's material.
*
* @type {?Material}
*/
get material() {
return this._mesh.material;
}
set material( value ) {
this._mesh.material = value;
}
}
export { Pass, FullScreenQuad };

View File

@ -0,0 +1,182 @@
import {
Color
} from 'three';
import { Pass } from './Pass.js';
/**
* This class represents a render pass. It takes a camera and a scene and produces
* a beauty pass for subsequent post processing effects.
*
* ```js
* const renderPass = new RenderPass( scene, camera );
* composer.addPass( renderPass );
* ```
*
* @augments Pass
*/
class RenderPass extends Pass {
/**
* Constructs a new render pass.
*
* @param {Scene} scene - The scene to render.
* @param {Camera} camera - The camera.
* @param {?Material} [overrideMaterial=null] - The override material. If set, this material is used
* for all objects in the scene.
* @param {?(number|Color|string)} [clearColor=null] - The clear color of the render pass.
* @param {?number} [clearAlpha=null] - The clear alpha of the render pass.
*/
constructor( scene, camera, overrideMaterial = null, clearColor = null, clearAlpha = null ) {
super();
/**
* The scene to render.
*
* @type {Scene}
*/
this.scene = scene;
/**
* The camera.
*
* @type {Camera}
*/
this.camera = camera;
/**
* The override material. If set, this material is used
* for all objects in the scene.
*
* @type {?Material}
* @default null
*/
this.overrideMaterial = overrideMaterial;
/**
* The clear color of the render pass.
*
* @type {?(number|Color|string)}
* @default null
*/
this.clearColor = clearColor;
/**
* The clear alpha of the render pass.
*
* @type {?number}
* @default null
*/
this.clearAlpha = clearAlpha;
/**
* Overwritten to perform a clear operation by default.
*
* @type {boolean}
* @default true
*/
this.clear = true;
/**
* If set to `true`, only the depth can be cleared when `clear` is to `false`.
*
* @type {boolean}
* @default false
*/
this.clearDepth = false;
/**
* Overwritten to disable the swap.
*
* @type {boolean}
* @default false
*/
this.needsSwap = false;
this._oldClearColor = new Color();
}
/**
* Performs a beauty pass with the configured scene and camera.
*
* @param {WebGLRenderer} renderer - The renderer.
* @param {WebGLRenderTarget} writeBuffer - The write buffer. This buffer is intended as the rendering
* destination for the pass.
* @param {WebGLRenderTarget} readBuffer - The read buffer. The pass can access the result from the
* previous pass from this buffer.
* @param {number} deltaTime - The delta time in seconds.
* @param {boolean} maskActive - Whether masking is active or not.
*/
render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
const oldAutoClear = renderer.autoClear;
renderer.autoClear = false;
let oldClearAlpha, oldOverrideMaterial;
if ( this.overrideMaterial !== null ) {
oldOverrideMaterial = this.scene.overrideMaterial;
this.scene.overrideMaterial = this.overrideMaterial;
}
if ( this.clearColor !== null ) {
renderer.getClearColor( this._oldClearColor );
renderer.setClearColor( this.clearColor, renderer.getClearAlpha() );
}
if ( this.clearAlpha !== null ) {
oldClearAlpha = renderer.getClearAlpha();
renderer.setClearAlpha( this.clearAlpha );
}
if ( this.clearDepth == true ) {
renderer.clearDepth();
}
renderer.setRenderTarget( this.renderToScreen ? null : readBuffer );
if ( this.clear === true ) {
// TODO: Avoid using autoClear properties, see https://github.com/mrdoob/three.js/pull/15571#issuecomment-465669600
renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
}
renderer.render( this.scene, this.camera );
// restore
if ( this.clearColor !== null ) {
renderer.setClearColor( this._oldClearColor );
}
if ( this.clearAlpha !== null ) {
renderer.setClearAlpha( oldClearAlpha );
}
if ( this.overrideMaterial !== null ) {
this.scene.overrideMaterial = oldOverrideMaterial;
}
renderer.autoClear = oldAutoClear;
}
}
export { RenderPass };

View File

@ -0,0 +1,134 @@
import {
ShaderMaterial,
UniformsUtils
} from 'three';
import { Pass, FullScreenQuad } from './Pass.js';
/**
* This pass can be used to create a post processing effect
* with a raw GLSL shader object. Useful for implementing custom
* effects.
*
* ```js
* const fxaaPass = new ShaderPass( FXAAShader );
* composer.addPass( fxaaPass );
* ```
*
* @augments Pass
*/
class ShaderPass extends Pass {
/**
* Constructs a new shader pass.
*
* @param {Object|ShaderMaterial} [shader] - A shader object holding vertex and fragment shader as well as
* defines and uniforms. It's also valid to pass a custom shader material.
* @param {string} [textureID='tDiffuse'] - The name of the texture uniform that should sample
* the read buffer.
*/
constructor( shader, textureID = 'tDiffuse' ) {
super();
/**
* The name of the texture uniform that should sample the read buffer.
*
* @type {string}
* @default 'tDiffuse'
*/
this.textureID = textureID;
/**
* The pass uniforms.
*
* @type {?Object}
*/
this.uniforms = null;
/**
* The pass material.
*
* @type {?ShaderMaterial}
*/
this.material = null;
if ( shader instanceof ShaderMaterial ) {
this.uniforms = shader.uniforms;
this.material = shader;
} else if ( shader ) {
this.uniforms = UniformsUtils.clone( shader.uniforms );
this.material = new ShaderMaterial( {
name: ( shader.name !== undefined ) ? shader.name : 'unspecified',
defines: Object.assign( {}, shader.defines ),
uniforms: this.uniforms,
vertexShader: shader.vertexShader,
fragmentShader: shader.fragmentShader
} );
}
// internals
this._fsQuad = new FullScreenQuad( this.material );
}
/**
* Performs the shader pass.
*
* @param {WebGLRenderer} renderer - The renderer.
* @param {WebGLRenderTarget} writeBuffer - The write buffer. This buffer is intended as the rendering
* destination for the pass.
* @param {WebGLRenderTarget} readBuffer - The read buffer. The pass can access the result from the
* previous pass from this buffer.
* @param {number} deltaTime - The delta time in seconds.
* @param {boolean} maskActive - Whether masking is active or not.
*/
render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
if ( this.uniforms[ this.textureID ] ) {
this.uniforms[ this.textureID ].value = readBuffer.texture;
}
this._fsQuad.material = this.material;
if ( this.renderToScreen ) {
renderer.setRenderTarget( null );
this._fsQuad.render( renderer );
} else {
renderer.setRenderTarget( writeBuffer );
// TODO: Avoid using autoClear properties, see https://github.com/mrdoob/three.js/pull/15571#issuecomment-465669600
if ( this.clear ) renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
this._fsQuad.render( renderer );
}
}
/**
* Frees the GPU-related resources allocated by this instance. Call this
* method whenever the pass is no longer used in your app.
*/
dispose() {
this.material.dispose();
this._fsQuad.dispose();
}
}
export { ShaderPass };

View File

@ -0,0 +1,491 @@
import {
AdditiveBlending,
Color,
HalfFloatType,
MeshBasicMaterial,
ShaderMaterial,
UniformsUtils,
Vector2,
Vector3,
WebGLRenderTarget
} from 'three';
import { Pass, FullScreenQuad } from './Pass.js';
import { CopyShader } from '../shaders/CopyShader.js';
import { LuminosityHighPassShader } from '../shaders/LuminosityHighPassShader.js';
/**
* This pass is inspired by the bloom pass of Unreal Engine. It creates a
* mip map chain of bloom textures and blurs them with different radii. Because
* of the weighted combination of mips, and because larger blurs are done on
* higher mips, this effect provides good quality and performance.
*
* When using this pass, tone mapping must be enabled in the renderer settings.
*
* Reference:
* - [Bloom in Unreal Engine]{@link https://docs.unrealengine.com/latest/INT/Engine/Rendering/PostProcessEffects/Bloom/}
*
* ```js
* const resolution = new THREE.Vector2( window.innerWidth, window.innerHeight );
* const bloomPass = new UnrealBloomPass( resolution, 1.5, 0.4, 0.85 );
* composer.addPass( bloomPass );
* ```
*
* @augments Pass
*/
class UnrealBloomPass extends Pass {
/**
* Constructs a new Unreal Bloom pass.
*
* @param {Vector2} [resolution] - The effect's resolution.
* @param {number} [strength=1] - The Bloom strength.
* @param {number} radius - The Bloom radius.
* @param {number} threshold - The luminance threshold limits which bright areas contribute to the Bloom effect.
*/
constructor( resolution, strength = 1, radius, threshold ) {
super();
/**
* The Bloom strength.
*
* @type {number}
* @default 1
*/
this.strength = strength;
/**
* The Bloom radius.
*
* @type {number}
*/
this.radius = radius;
/**
* The luminance threshold limits which bright areas contribute to the Bloom effect.
*
* @type {number}
*/
this.threshold = threshold;
/**
* The effect's resolution.
*
* @type {Vector2}
* @default (256,256)
*/
this.resolution = ( resolution !== undefined ) ? new Vector2( resolution.x, resolution.y ) : new Vector2( 256, 256 );
/**
* The effect's clear color
*
* @type {Color}
* @default (0,0,0)
*/
this.clearColor = new Color( 0, 0, 0 );
/**
* Overwritten to disable the swap.
*
* @type {boolean}
* @default false
*/
this.needsSwap = false;
// internals
// render targets
this.renderTargetsHorizontal = [];
this.renderTargetsVertical = [];
this.nMips = 5;
let resx = Math.round( this.resolution.x / 2 );
let resy = Math.round( this.resolution.y / 2 );
this.renderTargetBright = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
this.renderTargetBright.texture.name = 'UnrealBloomPass.bright';
this.renderTargetBright.texture.generateMipmaps = false;
for ( let i = 0; i < this.nMips; i ++ ) {
const renderTargetHorizontal = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
renderTargetHorizontal.texture.name = 'UnrealBloomPass.h' + i;
renderTargetHorizontal.texture.generateMipmaps = false;
this.renderTargetsHorizontal.push( renderTargetHorizontal );
const renderTargetVertical = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
renderTargetVertical.texture.name = 'UnrealBloomPass.v' + i;
renderTargetVertical.texture.generateMipmaps = false;
this.renderTargetsVertical.push( renderTargetVertical );
resx = Math.round( resx / 2 );
resy = Math.round( resy / 2 );
}
// luminosity high pass material
const highPassShader = LuminosityHighPassShader;
this.highPassUniforms = UniformsUtils.clone( highPassShader.uniforms );
this.highPassUniforms[ 'luminosityThreshold' ].value = threshold;
this.highPassUniforms[ 'smoothWidth' ].value = 0.01;
this.materialHighPassFilter = new ShaderMaterial( {
uniforms: this.highPassUniforms,
vertexShader: highPassShader.vertexShader,
fragmentShader: highPassShader.fragmentShader
} );
// gaussian blur materials
this.separableBlurMaterials = [];
const kernelSizeArray = [ 3, 5, 7, 9, 11 ];
resx = Math.round( this.resolution.x / 2 );
resy = Math.round( this.resolution.y / 2 );
for ( let i = 0; i < this.nMips; i ++ ) {
this.separableBlurMaterials.push( this._getSeparableBlurMaterial( kernelSizeArray[ i ] ) );
this.separableBlurMaterials[ i ].uniforms[ 'invSize' ].value = new Vector2( 1 / resx, 1 / resy );
resx = Math.round( resx / 2 );
resy = Math.round( resy / 2 );
}
// composite material
this.compositeMaterial = this._getCompositeMaterial( this.nMips );
this.compositeMaterial.uniforms[ 'blurTexture1' ].value = this.renderTargetsVertical[ 0 ].texture;
this.compositeMaterial.uniforms[ 'blurTexture2' ].value = this.renderTargetsVertical[ 1 ].texture;
this.compositeMaterial.uniforms[ 'blurTexture3' ].value = this.renderTargetsVertical[ 2 ].texture;
this.compositeMaterial.uniforms[ 'blurTexture4' ].value = this.renderTargetsVertical[ 3 ].texture;
this.compositeMaterial.uniforms[ 'blurTexture5' ].value = this.renderTargetsVertical[ 4 ].texture;
this.compositeMaterial.uniforms[ 'bloomStrength' ].value = strength;
this.compositeMaterial.uniforms[ 'bloomRadius' ].value = 0.1;
const bloomFactors = [ 1.0, 0.8, 0.6, 0.4, 0.2 ];
this.compositeMaterial.uniforms[ 'bloomFactors' ].value = bloomFactors;
this.bloomTintColors = [ new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ) ];
this.compositeMaterial.uniforms[ 'bloomTintColors' ].value = this.bloomTintColors;
// blend material
this.copyUniforms = UniformsUtils.clone( CopyShader.uniforms );
this.blendMaterial = new ShaderMaterial( {
uniforms: this.copyUniforms,
vertexShader: CopyShader.vertexShader,
fragmentShader: CopyShader.fragmentShader,
blending: AdditiveBlending,
depthTest: false,
depthWrite: false,
transparent: true
} );
this._oldClearColor = new Color();
this._oldClearAlpha = 1;
this._basic = new MeshBasicMaterial();
this._fsQuad = new FullScreenQuad( null );
}
/**
* Frees the GPU-related resources allocated by this instance. Call this
* method whenever the pass is no longer used in your app.
*/
dispose() {
for ( let i = 0; i < this.renderTargetsHorizontal.length; i ++ ) {
this.renderTargetsHorizontal[ i ].dispose();
}
for ( let i = 0; i < this.renderTargetsVertical.length; i ++ ) {
this.renderTargetsVertical[ i ].dispose();
}
this.renderTargetBright.dispose();
//
for ( let i = 0; i < this.separableBlurMaterials.length; i ++ ) {
this.separableBlurMaterials[ i ].dispose();
}
this.compositeMaterial.dispose();
this.blendMaterial.dispose();
this._basic.dispose();
//
this._fsQuad.dispose();
}
/**
* Sets the size of the pass.
*
* @param {number} width - The width to set.
* @param {number} height - The width to set.
*/
setSize( width, height ) {
let resx = Math.round( width / 2 );
let resy = Math.round( height / 2 );
this.renderTargetBright.setSize( resx, resy );
for ( let i = 0; i < this.nMips; i ++ ) {
this.renderTargetsHorizontal[ i ].setSize( resx, resy );
this.renderTargetsVertical[ i ].setSize( resx, resy );
this.separableBlurMaterials[ i ].uniforms[ 'invSize' ].value = new Vector2( 1 / resx, 1 / resy );
resx = Math.round( resx / 2 );
resy = Math.round( resy / 2 );
}
}
/**
* Performs the Bloom pass.
*
* @param {WebGLRenderer} renderer - The renderer.
* @param {WebGLRenderTarget} writeBuffer - The write buffer. This buffer is intended as the rendering
* destination for the pass.
* @param {WebGLRenderTarget} readBuffer - The read buffer. The pass can access the result from the
* previous pass from this buffer.
* @param {number} deltaTime - The delta time in seconds.
* @param {boolean} maskActive - Whether masking is active or not.
*/
render( renderer, writeBuffer, readBuffer, deltaTime, maskActive ) {
renderer.getClearColor( this._oldClearColor );
this._oldClearAlpha = renderer.getClearAlpha();
const oldAutoClear = renderer.autoClear;
renderer.autoClear = false;
renderer.setClearColor( this.clearColor, 0 );
if ( maskActive ) renderer.state.buffers.stencil.setTest( false );
// Render input to screen
if ( this.renderToScreen ) {
this._fsQuad.material = this._basic;
this._basic.map = readBuffer.texture;
renderer.setRenderTarget( null );
renderer.clear();
this._fsQuad.render( renderer );
}
// 1. Extract Bright Areas
this.highPassUniforms[ 'tDiffuse' ].value = readBuffer.texture;
this.highPassUniforms[ 'luminosityThreshold' ].value = this.threshold;
this._fsQuad.material = this.materialHighPassFilter;
renderer.setRenderTarget( this.renderTargetBright );
renderer.clear();
this._fsQuad.render( renderer );
// 2. Blur All the mips progressively
let inputRenderTarget = this.renderTargetBright;
for ( let i = 0; i < this.nMips; i ++ ) {
this._fsQuad.material = this.separableBlurMaterials[ i ];
this.separableBlurMaterials[ i ].uniforms[ 'colorTexture' ].value = inputRenderTarget.texture;
this.separableBlurMaterials[ i ].uniforms[ 'direction' ].value = UnrealBloomPass.BlurDirectionX;
renderer.setRenderTarget( this.renderTargetsHorizontal[ i ] );
renderer.clear();
this._fsQuad.render( renderer );
this.separableBlurMaterials[ i ].uniforms[ 'colorTexture' ].value = this.renderTargetsHorizontal[ i ].texture;
this.separableBlurMaterials[ i ].uniforms[ 'direction' ].value = UnrealBloomPass.BlurDirectionY;
renderer.setRenderTarget( this.renderTargetsVertical[ i ] );
renderer.clear();
this._fsQuad.render( renderer );
inputRenderTarget = this.renderTargetsVertical[ i ];
}
// Composite All the mips
this._fsQuad.material = this.compositeMaterial;
this.compositeMaterial.uniforms[ 'bloomStrength' ].value = this.strength;
this.compositeMaterial.uniforms[ 'bloomRadius' ].value = this.radius;
this.compositeMaterial.uniforms[ 'bloomTintColors' ].value = this.bloomTintColors;
renderer.setRenderTarget( this.renderTargetsHorizontal[ 0 ] );
renderer.clear();
this._fsQuad.render( renderer );
// Blend it additively over the input texture
this._fsQuad.material = this.blendMaterial;
this.copyUniforms[ 'tDiffuse' ].value = this.renderTargetsHorizontal[ 0 ].texture;
if ( maskActive ) renderer.state.buffers.stencil.setTest( true );
if ( this.renderToScreen ) {
renderer.setRenderTarget( null );
this._fsQuad.render( renderer );
} else {
renderer.setRenderTarget( readBuffer );
this._fsQuad.render( renderer );
}
// Restore renderer settings
renderer.setClearColor( this._oldClearColor, this._oldClearAlpha );
renderer.autoClear = oldAutoClear;
}
// internals
_getSeparableBlurMaterial( kernelRadius ) {
const coefficients = [];
for ( let i = 0; i < kernelRadius; i ++ ) {
coefficients.push( 0.39894 * Math.exp( - 0.5 * i * i / ( kernelRadius * kernelRadius ) ) / kernelRadius );
}
return new ShaderMaterial( {
defines: {
'KERNEL_RADIUS': kernelRadius
},
uniforms: {
'colorTexture': { value: null },
'invSize': { value: new Vector2( 0.5, 0.5 ) }, // inverse texture size
'direction': { value: new Vector2( 0.5, 0.5 ) },
'gaussianCoefficients': { value: coefficients } // precomputed Gaussian coefficients
},
vertexShader:
`varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader:
`#include <common>
varying vec2 vUv;
uniform sampler2D colorTexture;
uniform vec2 invSize;
uniform vec2 direction;
uniform float gaussianCoefficients[KERNEL_RADIUS];
void main() {
float weightSum = gaussianCoefficients[0];
vec3 diffuseSum = texture2D( colorTexture, vUv ).rgb * weightSum;
for( int i = 1; i < KERNEL_RADIUS; i ++ ) {
float x = float(i);
float w = gaussianCoefficients[i];
vec2 uvOffset = direction * invSize * x;
vec3 sample1 = texture2D( colorTexture, vUv + uvOffset ).rgb;
vec3 sample2 = texture2D( colorTexture, vUv - uvOffset ).rgb;
diffuseSum += (sample1 + sample2) * w;
weightSum += 2.0 * w;
}
gl_FragColor = vec4(diffuseSum/weightSum, 1.0);
}`
} );
}
_getCompositeMaterial( nMips ) {
return new ShaderMaterial( {
defines: {
'NUM_MIPS': nMips
},
uniforms: {
'blurTexture1': { value: null },
'blurTexture2': { value: null },
'blurTexture3': { value: null },
'blurTexture4': { value: null },
'blurTexture5': { value: null },
'bloomStrength': { value: 1.0 },
'bloomFactors': { value: null },
'bloomTintColors': { value: null },
'bloomRadius': { value: 0.0 }
},
vertexShader:
`varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader:
`varying vec2 vUv;
uniform sampler2D blurTexture1;
uniform sampler2D blurTexture2;
uniform sampler2D blurTexture3;
uniform sampler2D blurTexture4;
uniform sampler2D blurTexture5;
uniform float bloomStrength;
uniform float bloomRadius;
uniform float bloomFactors[NUM_MIPS];
uniform vec3 bloomTintColors[NUM_MIPS];
float lerpBloomFactor(const in float factor) {
float mirrorFactor = 1.2 - factor;
return mix(factor, mirrorFactor, bloomRadius);
}
void main() {
gl_FragColor = bloomStrength * ( lerpBloomFactor(bloomFactors[0]) * vec4(bloomTintColors[0], 1.0) * texture2D(blurTexture1, vUv) +
lerpBloomFactor(bloomFactors[1]) * vec4(bloomTintColors[1], 1.0) * texture2D(blurTexture2, vUv) +
lerpBloomFactor(bloomFactors[2]) * vec4(bloomTintColors[2], 1.0) * texture2D(blurTexture3, vUv) +
lerpBloomFactor(bloomFactors[3]) * vec4(bloomTintColors[3], 1.0) * texture2D(blurTexture4, vUv) +
lerpBloomFactor(bloomFactors[4]) * vec4(bloomTintColors[4], 1.0) * texture2D(blurTexture5, vUv) );
}`
} );
}
}
UnrealBloomPass.BlurDirectionX = new Vector2( 1.0, 0.0 );
UnrealBloomPass.BlurDirectionY = new Vector2( 0.0, 1.0 );
export { UnrealBloomPass };

View File

@ -0,0 +1,49 @@
/** @module CopyShader */
/**
* Full-screen copy shader pass.
*
* @constant
* @type {ShaderMaterial~Shader}
*/
const CopyShader = {
name: 'CopyShader',
uniforms: {
'tDiffuse': { value: null },
'opacity': { value: 1.0 }
},
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: /* glsl */`
uniform float opacity;
uniform sampler2D tDiffuse;
varying vec2 vUv;
void main() {
vec4 texel = texture2D( tDiffuse, vUv );
gl_FragColor = opacity * texel;
}`
};
export { CopyShader };

View File

@ -0,0 +1,65 @@
import {
Color
} from 'three';
/** @module LuminosityHighPassShader */
/**
* Luminosity high pass shader.
*
* @constant
* @type {ShaderMaterial~Shader}
*/
const LuminosityHighPassShader = {
name: 'LuminosityHighPassShader',
uniforms: {
'tDiffuse': { value: null },
'luminosityThreshold': { value: 1.0 },
'smoothWidth': { value: 1.0 },
'defaultColor': { value: new Color( 0x000000 ) },
'defaultOpacity': { value: 0.0 }
},
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: /* glsl */`
uniform sampler2D tDiffuse;
uniform vec3 defaultColor;
uniform float defaultOpacity;
uniform float luminosityThreshold;
uniform float smoothWidth;
varying vec2 vUv;
void main() {
vec4 texel = texture2D( tDiffuse, vUv );
float v = luminance( texel.xyz );
vec4 outputColor = vec4( defaultColor.rgb, defaultOpacity );
float alpha = smoothstep( luminosityThreshold, luminosityThreshold + smoothWidth, v );
gl_FragColor = mix( outputColor, texel, alpha );
}`
};
export { LuminosityHighPassShader };

View File

@ -0,0 +1,100 @@
/** @module OutputShader */
/**
* Performs tone mapping and color space conversion for
* FX workflows.
*
* Used by {@link OutputPass}.
*
* @constant
* @type {ShaderMaterial~Shader}
*/
const OutputShader = {
name: 'OutputShader',
uniforms: {
'tDiffuse': { value: null },
'toneMappingExposure': { value: 1 }
},
vertexShader: /* glsl */`
precision highp float;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
attribute vec3 position;
attribute vec2 uv;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: /* glsl */`
precision highp float;
uniform sampler2D tDiffuse;
#include <tonemapping_pars_fragment>
#include <colorspace_pars_fragment>
varying vec2 vUv;
void main() {
gl_FragColor = texture2D( tDiffuse, vUv );
// tone mapping
#ifdef LINEAR_TONE_MAPPING
gl_FragColor.rgb = LinearToneMapping( gl_FragColor.rgb );
#elif defined( REINHARD_TONE_MAPPING )
gl_FragColor.rgb = ReinhardToneMapping( gl_FragColor.rgb );
#elif defined( CINEON_TONE_MAPPING )
gl_FragColor.rgb = CineonToneMapping( gl_FragColor.rgb );
#elif defined( ACES_FILMIC_TONE_MAPPING )
gl_FragColor.rgb = ACESFilmicToneMapping( gl_FragColor.rgb );
#elif defined( AGX_TONE_MAPPING )
gl_FragColor.rgb = AgXToneMapping( gl_FragColor.rgb );
#elif defined( NEUTRAL_TONE_MAPPING )
gl_FragColor.rgb = NeutralToneMapping( gl_FragColor.rgb );
#elif defined( CUSTOM_TONE_MAPPING )
gl_FragColor.rgb = CustomToneMapping( gl_FragColor.rgb );
#endif
// color space
#ifdef SRGB_TRANSFER
gl_FragColor = sRGBTransferOETF( gl_FragColor );
#endif
}`
};
export { OutputShader };

View File

@ -0,0 +1,53 @@
/** @module VignetteShader */
/**
* Based on [PaintEffect postprocess from ro.me]{@link http://code.google.com/p/3-dreams-of-black/source/browse/deploy/js/effects/PaintEffect.js}.
*
* @constant
* @type {ShaderMaterial~Shader}
*/
const VignetteShader = {
name: 'VignetteShader',
uniforms: {
'tDiffuse': { value: null },
'offset': { value: 1.0 },
'darkness': { value: 1.0 }
},
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: /* glsl */`
uniform float offset;
uniform float darkness;
uniform sampler2D tDiffuse;
varying vec2 vUv;
void main() {
// Eskil's vignette
vec4 texel = texture2D( tDiffuse, vUv );
vec2 uv = ( vUv - vec2( 0.5 ) ) * vec2( offset );
gl_FragColor = vec4( mix( texel.rgb, vec3( 1.0 - darkness ), dot( uv, uv ) ), texel.a );
}`
};
export { VignetteShader };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,490 @@
import {
AnimationClip,
AnimationMixer,
Matrix4,
Quaternion,
QuaternionKeyframeTrack,
SkeletonHelper,
Vector3,
VectorKeyframeTrack
} from 'three';
/**
* @module SkeletonUtils
* @three_import import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
*/
function getBoneName( bone, options ) {
if ( options.getBoneName !== undefined ) {
return options.getBoneName( bone );
}
return options.names[ bone.name ];
}
/**
* Retargets the skeleton from the given source 3D object to the
* target 3D object.
*
* @param {Object3D} target - The target 3D object.
* @param {Object3D} source - The source 3D object.
* @param {module:SkeletonUtils~RetargetOptions} options - The options.
*/
function retarget( target, source, options = {} ) {
const quat = new Quaternion(),
scale = new Vector3(),
relativeMatrix = new Matrix4(),
globalMatrix = new Matrix4();
options.preserveBoneMatrix = options.preserveBoneMatrix !== undefined ? options.preserveBoneMatrix : true;
options.preserveBonePositions = options.preserveBonePositions !== undefined ? options.preserveBonePositions : true;
options.useTargetMatrix = options.useTargetMatrix !== undefined ? options.useTargetMatrix : false;
options.hip = options.hip !== undefined ? options.hip : 'hip';
options.hipInfluence = options.hipInfluence !== undefined ? options.hipInfluence : new Vector3( 1, 1, 1 );
options.scale = options.scale !== undefined ? options.scale : 1;
options.names = options.names || {};
const sourceBones = source.isObject3D ? source.skeleton.bones : getBones( source ),
bones = target.isObject3D ? target.skeleton.bones : getBones( target );
let bone, name, boneTo,
bonesPosition;
// reset bones
if ( target.isObject3D ) {
target.skeleton.pose();
} else {
options.useTargetMatrix = true;
options.preserveBoneMatrix = false;
}
if ( options.preserveBonePositions ) {
bonesPosition = [];
for ( let i = 0; i < bones.length; i ++ ) {
bonesPosition.push( bones[ i ].position.clone() );
}
}
if ( options.preserveBoneMatrix ) {
// reset matrix
target.updateMatrixWorld();
target.matrixWorld.identity();
// reset children matrix
for ( let i = 0; i < target.children.length; ++ i ) {
target.children[ i ].updateMatrixWorld( true );
}
}
for ( let i = 0; i < bones.length; ++ i ) {
bone = bones[ i ];
name = getBoneName( bone, options );
boneTo = getBoneByName( name, sourceBones );
globalMatrix.copy( bone.matrixWorld );
if ( boneTo ) {
boneTo.updateMatrixWorld();
if ( options.useTargetMatrix ) {
relativeMatrix.copy( boneTo.matrixWorld );
} else {
relativeMatrix.copy( target.matrixWorld ).invert();
relativeMatrix.multiply( boneTo.matrixWorld );
}
// ignore scale to extract rotation
scale.setFromMatrixScale( relativeMatrix );
relativeMatrix.scale( scale.set( 1 / scale.x, 1 / scale.y, 1 / scale.z ) );
// apply to global matrix
globalMatrix.makeRotationFromQuaternion( quat.setFromRotationMatrix( relativeMatrix ) );
if ( target.isObject3D ) {
if ( options.localOffsets ) {
if ( options.localOffsets[ bone.name ] ) {
globalMatrix.multiply( options.localOffsets[ bone.name ] );
}
}
}
globalMatrix.copyPosition( relativeMatrix );
}
if ( name === options.hip ) {
globalMatrix.elements[ 12 ] *= options.scale * options.hipInfluence.x;
globalMatrix.elements[ 13 ] *= options.scale * options.hipInfluence.y;
globalMatrix.elements[ 14 ] *= options.scale * options.hipInfluence.z;
if ( options.hipPosition !== undefined ) {
globalMatrix.elements[ 12 ] += options.hipPosition.x * options.scale;
globalMatrix.elements[ 13 ] += options.hipPosition.y * options.scale;
globalMatrix.elements[ 14 ] += options.hipPosition.z * options.scale;
}
}
if ( bone.parent ) {
bone.matrix.copy( bone.parent.matrixWorld ).invert();
bone.matrix.multiply( globalMatrix );
} else {
bone.matrix.copy( globalMatrix );
}
bone.matrix.decompose( bone.position, bone.quaternion, bone.scale );
bone.updateMatrixWorld();
}
if ( options.preserveBonePositions ) {
for ( let i = 0; i < bones.length; ++ i ) {
bone = bones[ i ];
name = getBoneName( bone, options ) || bone.name;
if ( name !== options.hip ) {
bone.position.copy( bonesPosition[ i ] );
}
}
}
if ( options.preserveBoneMatrix ) {
// restore matrix
target.updateMatrixWorld( true );
}
}
/**
* Retargets the animation clip of the source object to the
* target 3D object.
*
* @param {Object3D} target - The target 3D object.
* @param {Object3D} source - The source 3D object.
* @param {AnimationClip} clip - The animation clip.
* @param {module:SkeletonUtils~RetargetOptions} options - The options.
* @return {AnimationClip} The retargeted animation clip.
*/
function retargetClip( target, source, clip, options = {} ) {
options.useFirstFramePosition = options.useFirstFramePosition !== undefined ? options.useFirstFramePosition : false;
// Calculate the fps from the source clip based on the track with the most frames, unless fps is already provided.
options.fps = options.fps !== undefined ? options.fps : ( Math.max( ...clip.tracks.map( track => track.times.length ) ) / clip.duration );
options.names = options.names || [];
if ( ! source.isObject3D ) {
source = getHelperFromSkeleton( source );
}
const numFrames = Math.round( clip.duration * ( options.fps / 1000 ) * 1000 ),
delta = clip.duration / ( numFrames - 1 ),
convertedTracks = [],
mixer = new AnimationMixer( source ),
bones = getBones( target.skeleton ),
boneDatas = [];
let positionOffset,
bone, boneTo, boneData,
name;
mixer.clipAction( clip ).play();
// trim
let start = 0, end = numFrames;
if ( options.trim !== undefined ) {
start = Math.round( options.trim[ 0 ] * options.fps );
end = Math.min( Math.round( options.trim[ 1 ] * options.fps ), numFrames ) - start;
mixer.update( options.trim[ 0 ] );
} else {
mixer.update( 0 );
}
source.updateMatrixWorld();
//
for ( let frame = 0; frame < end; ++ frame ) {
const time = frame * delta;
retarget( target, source, options );
for ( let j = 0; j < bones.length; ++ j ) {
bone = bones[ j ];
name = getBoneName( bone, options ) || bone.name;
boneTo = getBoneByName( name, source.skeleton );
if ( boneTo ) {
boneData = boneDatas[ j ] = boneDatas[ j ] || { bone: bone };
if ( options.hip === name ) {
if ( ! boneData.pos ) {
boneData.pos = {
times: new Float32Array( end ),
values: new Float32Array( end * 3 )
};
}
if ( options.useFirstFramePosition ) {
if ( frame === 0 ) {
positionOffset = bone.position.clone();
}
bone.position.sub( positionOffset );
}
boneData.pos.times[ frame ] = time;
bone.position.toArray( boneData.pos.values, frame * 3 );
}
if ( ! boneData.quat ) {
boneData.quat = {
times: new Float32Array( end ),
values: new Float32Array( end * 4 )
};
}
boneData.quat.times[ frame ] = time;
bone.quaternion.toArray( boneData.quat.values, frame * 4 );
}
}
if ( frame === end - 2 ) {
// last mixer update before final loop iteration
// make sure we do not go over or equal to clip duration
mixer.update( delta - 0.0000001 );
} else {
mixer.update( delta );
}
source.updateMatrixWorld();
}
for ( let i = 0; i < boneDatas.length; ++ i ) {
boneData = boneDatas[ i ];
if ( boneData ) {
if ( boneData.pos ) {
convertedTracks.push( new VectorKeyframeTrack(
'.bones[' + boneData.bone.name + '].position',
boneData.pos.times,
boneData.pos.values
) );
}
convertedTracks.push( new QuaternionKeyframeTrack(
'.bones[' + boneData.bone.name + '].quaternion',
boneData.quat.times,
boneData.quat.values
) );
}
}
mixer.uncacheAction( clip );
return new AnimationClip( clip.name, - 1, convertedTracks );
}
/**
* Clones the given 3D object and its descendants, ensuring that any `SkinnedMesh` instances are
* correctly associated with their bones. Bones are also cloned, and must be descendants of the
* object passed to this method. Other data, like geometries and materials, are reused by reference.
*
* @param {Object3D} source - The 3D object to clone.
* @return {Object3D} The cloned 3D object.
*/
function clone( source ) {
const sourceLookup = new Map();
const cloneLookup = new Map();
const clone = source.clone();
parallelTraverse( source, clone, function ( sourceNode, clonedNode ) {
sourceLookup.set( clonedNode, sourceNode );
cloneLookup.set( sourceNode, clonedNode );
} );
clone.traverse( function ( node ) {
if ( ! node.isSkinnedMesh ) return;
const clonedMesh = node;
const sourceMesh = sourceLookup.get( node );
const sourceBones = sourceMesh.skeleton.bones;
clonedMesh.skeleton = sourceMesh.skeleton.clone();
clonedMesh.bindMatrix.copy( sourceMesh.bindMatrix );
clonedMesh.skeleton.bones = sourceBones.map( function ( bone ) {
return cloneLookup.get( bone );
} );
clonedMesh.bind( clonedMesh.skeleton, clonedMesh.bindMatrix );
} );
return clone;
}
// internal helper
function getBoneByName( name, skeleton ) {
for ( let i = 0, bones = getBones( skeleton ); i < bones.length; i ++ ) {
if ( name === bones[ i ].name )
return bones[ i ];
}
}
function getBones( skeleton ) {
return Array.isArray( skeleton ) ? skeleton : skeleton.bones;
}
function getHelperFromSkeleton( skeleton ) {
const source = new SkeletonHelper( skeleton.bones[ 0 ] );
source.skeleton = skeleton;
return source;
}
function parallelTraverse( a, b, callback ) {
callback( a, b );
for ( let i = 0; i < a.children.length; i ++ ) {
parallelTraverse( a.children[ i ], b.children[ i ], callback );
}
}
/**
* Retarget options of `SkeletonUtils`.
*
* @typedef {Object} module:SkeletonUtils~RetargetOptions
* @property {boolean} [useFirstFramePosition=false] - Whether to use the position of the first frame or not.
* @property {number} [fps] - The FPS of the clip.
* @property {Object<string,string>} [names] - A dictionary for mapping target to source bone names.
* @property {function(string):string} [getBoneName] - A function for mapping bone names. Alternative to `names`.
* @property {Array<number>} [trim] - Whether to trim the clip or not. If set the array should hold two values for the start and end.
* @property {boolean} [preserveBoneMatrix=true] - Whether to preserve bone matrices or not.
* @property {boolean} [preserveBonePositions=true] - Whether to preserve bone positions or not.
* @property {boolean} [useTargetMatrix=false] - Whether to use the target matrix or not.
* @property {string} [hip='hip'] - The name of the source's hip bone.
* @property {Vector3} [hipInfluence=(1,1,1)] - The hip influence.
* @property {number} [scale=1] - The scale.
**/
export {
retarget,
retargetClip,
clone,
};

57362
web/world/vendor/three.core.js vendored Normal file

File diff suppressed because one or more lines are too long

17990
web/world/vendor/three.module.js vendored Normal file

File diff suppressed because one or more lines are too long