Compare commits
12 Commits
181dc82f1f
...
302972cc6e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
302972cc6e | ||
|
|
78c98aed64 | ||
|
|
219dd55716 | ||
|
|
3815055678 | ||
|
|
d8a017ad7d | ||
|
|
8d76340f49 | ||
|
|
868ea5699d | ||
|
|
1049dece24 | ||
|
|
a41328a219 | ||
|
|
ae6e444996 | ||
|
|
664e378578 | ||
|
|
027fb99828 |
@ -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
17
.gitignore
vendored
Normal 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
|
||||
73
PLAN3D.md
73
PLAN3D.md
@ -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
|
||||
|
||||
161
THREADS.md
161
THREADS.md
@ -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
137
server.py
Normal 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()
|
||||
50
tools/assetcheck/README.md
Normal file
50
tools/assetcheck/README.md
Normal 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"
|
||||
```
|
||||
82
tools/assetcheck/assets_in_three.html
Normal file
82
tools/assetcheck/assets_in_three.html
Normal 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>
|
||||
328
tools/blender/asset_report.json
Normal file
328
tools/blender/asset_report.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
1409
tools/blender/build_yard_assets.py
Normal file
1409
tools/blender/build_yard_assets.py
Normal file
File diff suppressed because it is too large
Load Diff
BIN
tools/blender/contact_sheet.png
Normal file
BIN
tools/blender/contact_sheet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
164
tools/character/build_player_anims.py
Normal file
164
tools/character/build_player_anims.py
Normal 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
38
web/world/index.html
Normal 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
176
web/world/js/camera.js
Normal 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
344
web/world/js/contracts.js
Normal 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.6–1.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
239
web/world/js/main.js
Normal 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
107
web/world/js/testkit.js
Normal 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 };
|
||||
}
|
||||
228
web/world/js/tests/a.test.js
Normal file
228
web/world/js/tests/a.test.js
Normal 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');
|
||||
});
|
||||
}
|
||||
26
web/world/js/tests/b.test.js
Normal file
26
web/world/js/tests/b.test.js
Normal 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');
|
||||
}
|
||||
25
web/world/js/tests/c.test.js
Normal file
25
web/world/js/tests/c.test.js
Normal 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');
|
||||
}
|
||||
26
web/world/js/tests/d.test.js
Normal file
26
web/world/js/tests/d.test.js
Normal 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');
|
||||
}
|
||||
160
web/world/js/tests/e.test.js
Normal file
160
web/world/js/tests/e.test.js
Normal 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-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
|
||||
* 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 1–6 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
401
web/world/js/world.js
Normal 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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
13
web/world/models/MODELS.md
Normal file
13
web/world/models/MODELS.md
Normal 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.
|
||||
BIN
web/world/models/carabiner_v1.glb
Normal file
BIN
web/world/models/carabiner_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/debris/BlackTub_v2.glb
Normal file
BIN
web/world/models/debris/BlackTub_v2.glb
Normal file
Binary file not shown.
BIN
web/world/models/debris/BlueCrate_v2.glb
Normal file
BIN
web/world/models/debris/BlueCrate_v2.glb
Normal file
Binary file not shown.
BIN
web/world/models/debris/WhiteTub_v2.glb
Normal file
BIN
web/world/models/debris/WhiteTub_v2.glb
Normal file
Binary file not shown.
BIN
web/world/models/debris/WoodenBin_v2.glb
Normal file
BIN
web/world/models/debris/WoodenBin_v2.glb
Normal file
Binary file not shown.
BIN
web/world/models/debris/tramp_01_v1.glb
Normal file
BIN
web/world/models/debris/tramp_01_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/fence_panel_v1.glb
Normal file
BIN
web/world/models/fence_panel_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/fence_post_v1.glb
Normal file
BIN
web/world/models/fence_post_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/garden_bed_v1.glb
Normal file
BIN
web/world/models/garden_bed_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/gate_v1.glb
Normal file
BIN
web/world/models/gate_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/house_yardside_v1.glb
Normal file
BIN
web/world/models/house_yardside_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/ladder_01_v1.glb
Normal file
BIN
web/world/models/ladder_01_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/player_01.glb
Normal file
BIN
web/world/models/player_01.glb
Normal file
Binary file not shown.
BIN
web/world/models/player_anims.glb
Normal file
BIN
web/world/models/player_anims.glb
Normal file
Binary file not shown.
BIN
web/world/models/ref_capsule_v1.glb
Normal file
BIN
web/world/models/ref_capsule_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/sail_post_v1.glb
Normal file
BIN
web/world/models/sail_post_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/shackle_v1.glb
Normal file
BIN
web/world/models/shackle_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/shed_01_v1.glb
Normal file
BIN
web/world/models/shed_01_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/shed_table_v1.glb
Normal file
BIN
web/world/models/shed_table_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/textures/grass_atlas.png
Normal file
BIN
web/world/models/textures/grass_atlas.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
web/world/models/tree_gum_01_v1.glb
Normal file
BIN
web/world/models/tree_gum_01_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/tree_gum_02_v1.glb
Normal file
BIN
web/world/models/tree_gum_02_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/turnbuckle_v1.glb
Normal file
BIN
web/world/models/turnbuckle_v1.glb
Normal file
Binary file not shown.
97
web/world/selftest.html
Normal file
97
web/world/selftest.html
Normal 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>
|
||||
270
web/world/vendor/addons/controls/PointerLockControls.js
vendored
Normal file
270
web/world/vendor/addons/controls/PointerLockControls.js
vendored
Normal 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 };
|
||||
4958
web/world/vendor/addons/loaders/GLTFLoader.js
vendored
Normal file
4958
web/world/vendor/addons/loaders/GLTFLoader.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
361
web/world/vendor/addons/postprocessing/EffectComposer.js
vendored
Normal file
361
web/world/vendor/addons/postprocessing/EffectComposer.js
vendored
Normal 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 };
|
||||
194
web/world/vendor/addons/postprocessing/MaskPass.js
vendored
Normal file
194
web/world/vendor/addons/postprocessing/MaskPass.js
vendored
Normal 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 };
|
||||
138
web/world/vendor/addons/postprocessing/OutputPass.js
vendored
Normal file
138
web/world/vendor/addons/postprocessing/OutputPass.js
vendored
Normal 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 };
|
||||
189
web/world/vendor/addons/postprocessing/Pass.js
vendored
Normal file
189
web/world/vendor/addons/postprocessing/Pass.js
vendored
Normal 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 };
|
||||
182
web/world/vendor/addons/postprocessing/RenderPass.js
vendored
Normal file
182
web/world/vendor/addons/postprocessing/RenderPass.js
vendored
Normal 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 };
|
||||
134
web/world/vendor/addons/postprocessing/ShaderPass.js
vendored
Normal file
134
web/world/vendor/addons/postprocessing/ShaderPass.js
vendored
Normal 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 };
|
||||
491
web/world/vendor/addons/postprocessing/UnrealBloomPass.js
vendored
Normal file
491
web/world/vendor/addons/postprocessing/UnrealBloomPass.js
vendored
Normal 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 };
|
||||
49
web/world/vendor/addons/shaders/CopyShader.js
vendored
Normal file
49
web/world/vendor/addons/shaders/CopyShader.js
vendored
Normal 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 };
|
||||
65
web/world/vendor/addons/shaders/LuminosityHighPassShader.js
vendored
Normal file
65
web/world/vendor/addons/shaders/LuminosityHighPassShader.js
vendored
Normal 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 };
|
||||
100
web/world/vendor/addons/shaders/OutputShader.js
vendored
Normal file
100
web/world/vendor/addons/shaders/OutputShader.js
vendored
Normal 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 };
|
||||
53
web/world/vendor/addons/shaders/VignetteShader.js
vendored
Normal file
53
web/world/vendor/addons/shaders/VignetteShader.js
vendored
Normal 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 };
|
||||
1432
web/world/vendor/addons/utils/BufferGeometryUtils.js
vendored
Normal file
1432
web/world/vendor/addons/utils/BufferGeometryUtils.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
490
web/world/vendor/addons/utils/SkeletonUtils.js
vendored
Normal file
490
web/world/vendor/addons/utils/SkeletonUtils.js
vendored
Normal 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
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
17990
web/world/vendor/three.module.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user