@genex-ai/cli-demo 0.78.0-dev.200 → 0.80.0-dev.211

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/dist/index.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/README.md +1 -1
  4. package/templates/controllers/quality/pick-asset.ts +12 -7
  5. package/templates/skills/genex-ai-character/SKILL.md +2 -4
  6. package/templates/skills/genex-ai-hud/SKILL.md +69 -23
  7. package/templates/skills/genex-ai-menu/SKILL.md +17 -12
  8. package/templates/skills/genex-ai-model/SKILL.md +58 -7
  9. package/templates/skills/genex-game-director/SKILL.md +265 -0
  10. package/templates/skills/genex-game-director/references/design-contract.md +148 -0
  11. package/templates/skills/{genex-threejs-skill-router → genex-game-director}/references/routing-map.md +82 -63
  12. package/templates/skills/genex-getting-started/SKILL.md +3 -2
  13. package/templates/skills/genex-threejs-adaptive-quality/SKILL.md +23 -8
  14. package/templates/skills/genex-threejs-adaptive-quality/references/adaptive-quality.md +16 -4
  15. package/templates/skills/genex-threejs-character-controller/SKILL.md +2 -4
  16. package/templates/skills/genex-threejs-embed-auth/SKILL.md +6 -48
  17. package/templates/skills/genex-threejs-game-content/SKILL.md +3 -4
  18. package/templates/skills/genex-threejs-game-ui/SKILL.md +95 -76
  19. package/templates/skills/genex-threejs-game-ui/references/style-capsules.md +9 -5
  20. package/templates/skills/genex-threejs-lighting-design/SKILL.md +7 -0
  21. package/templates/skills/genex-threejs-multiplayer/SKILL.md +1 -2
  22. package/templates/skills/genex-threejs-visual-validation/SKILL.md +2 -4
  23. package/templates/skills/genex-threejs-skill-router/SKILL.md +0 -263
package/dist/index.js CHANGED
@@ -287,7 +287,7 @@ async function hasGenexSkills(skillsDir) {
287
287
  return false;
288
288
  }
289
289
  }
290
- var REMOVED_SKILLS = ["genex-explore"];
290
+ var REMOVED_SKILLS = ["genex-explore", "genex-threejs-skill-router"];
291
291
  async function pruneRemovedSkills(skillsDir, log) {
292
292
  const removed = [];
293
293
  for (const name of REMOVED_SKILLS) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.78.0-dev.200",
3
+ "version": "0.80.0-dev.211",
4
4
  "description": "Set up your project's agent workspace (.claude/.codex/.cursor in the game folder), authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,5 +17,5 @@ command keeps them in sync with the installed CLI version automatically
17
17
  - `agents/` - example subagent definitions.
18
18
  - `commands/` - example slash commands.
19
19
 
20
- Start with `skills/genex-threejs-skill-router/SKILL.md` when asking your agent
20
+ Start with `skills/genex-game-director/SKILL.md` when asking your agent
21
21
  to build or improve a 3D browser game.
@@ -80,26 +80,31 @@ export async function loadTextureWithFallback<T>(
80
80
  // Generated MODELS (mesh-compression lane). GLB rungs use the same numeric
81
81
  // suffix — `model-glb@1024` = embedded textures ≤1024 + meshopt (+simplify) —
82
82
  // but unlike images there is NO edge rewrite for old games: selection happens
83
- // ONLY here, in games that wired the decoders (createGltfLoader). Desktop
84
- // always loads the original.
83
+ // ONLY here, in games that wired the decoders (createGltfLoader). EVERY tier
84
+ // loads a game-ready rung — provider-raw originals are archival/remix source,
85
+ // not game assets (a Tripo prop is ~500k tris + 3x4096² textures; a scene of
86
+ // them floored an M4 Max). Desktop gets @2048, phones @1024; the original is
87
+ // only ever the fallback of last resort.
85
88
 
86
- const MODEL_TEXTURE_BUDGET = 1024;
89
+ /** Per-tier texture budget: desktop tiers @2048, phone tiers @1024. */
90
+ function modelBudgetFor(tier: QualityTier): number {
91
+ return tier.name === "phone" || tier.name === "phone-low" ? 1024 : 2048;
92
+ }
87
93
  const MODEL_ROLE_RE = /^(model-glb|character-rigged(-a\d+)?-glb(-r\d+)?)$/;
88
94
 
89
95
  /** Resolve the model URL a THIS-tier device should load. `ktx2: true` (from
90
- * createGltfLoader) upgrades phones to the GPU-compressed sibling. */
96
+ * createGltfLoader) upgrades to the GPU-compressed sibling. */
91
97
  export function pickModel(url: string, tier: QualityTier, opts?: { ktx2?: boolean }): string {
92
98
  if (!GENEX_GENERATIONS_RE.test(url)) return url;
93
99
  const role = url.split("/").pop() ?? "";
94
100
  if (!MODEL_ROLE_RE.test(role)) return url;
95
- if (tier.name !== "phone" && tier.name !== "phone-low") return url;
96
- const rung = `${url}@${MODEL_TEXTURE_BUDGET}`;
101
+ const rung = `${url}@${modelBudgetFor(tier)}`;
97
102
  return opts?.ktx2 ? `${rung}.ktx2` : rung;
98
103
  }
99
104
 
100
105
  /**
101
106
  * Load a generated model through the rung ladder with the full fallback chain
102
- * (`.ktx2` → `@1024` → original). Use with the decoder-wired loader:
107
+ * (`.ktx2` → the tier's rung → original). Use with the decoder-wired loader:
103
108
  *
104
109
  * const gltf = createGltfLoader(renderer);
105
110
  * const model = await loadModelWithFallback(MODEL_URL, tier, (u) => gltf.loader.loadAsync(u), { ktx2: gltf.ktx2 });
@@ -43,10 +43,8 @@ Meshy Image-to-3D first produces an unremeshed high-detail model. Show its
43
43
  front, back, left, and right views and report its measured face count. Preserve
44
44
  that model in R2. Before rigging, ask the user to approve a separate
45
45
  10,000-face triangle remesh. The 10k remesh—not the high-detail source—is
46
- rigged and animated. (For these approvals, use your environment's structured
47
- question tool when it has one Claude Code: `AskUserQuestion`; Codex:
48
- `request_user_input`; if it has none, e.g. Cursor, a short numbered list in
49
- chat.)
46
+ rigged and animated. (For these approvals, use your question tool when you
47
+ have one; if you have none, a short numbered list in chat.)
50
48
 
51
49
  The high-detail pre-rig generation stays in the selected neutral A-pose. There
52
50
  is no dynamic-pose concept and no silent T-pose fallback. After the user has
@@ -35,18 +35,25 @@ ring) that no hand-written CSS can fake.
35
35
  (`background: rgba(<darkest hue>, 0.35–0.6)` + `backdrop-filter: blur(6–12px)`
36
36
  and its `-webkit-` twin) when the style wants translucency, a solid painted
37
37
  plate, a subtle gradient — or no plate at all for an outline-led look.
38
+ **A plate exists only where the widget's own art doesn't already back it.**
39
+ Behind an OPAQUE frame sprite there is no plate — the frame IS the backing
40
+ surface, and a dark div stacked behind it only protrudes as a box over the
41
+ scene (the recurring black-box defect). Where a plate IS needed — bare DOM
42
+ readouts, thin-outline widgets — it stays WITHIN the widget's silhouette:
43
+ inset inside the frame, or shaped to it, never a full rectangle spilling
44
+ past the art onto the scene.
38
45
  **Glass is a technique, not a default** — do not reach for translucency
39
46
  because this skill mentions it; reach for it when THIS game's brief does.
40
47
  What is fixed is WHERE plates are built — the DOM — because
41
48
  semi-transparency physically cannot survive sprite extraction: a composited
42
49
  panel's pixels are a blend of panel and background, and no cutout can
43
50
  un-blend them. Plates are NEVER baked into sprites. Give the plate its
44
- corners with `border-radius` or a clean `clip-path`/`mask` shape — a CSS
45
- chamfer/notch is allowed, as long as the cut never clips the plate's
46
- content (text, padding, a glow), leaves no jagged-edge artifacts, and
47
- nothing ends up crooked; a genuinely ornamented angular frame is still
48
- best as chrome, generated. The load-bearing `mask`/`clip-path` in this
49
- skill is the masked-fill reveal below.
51
+ corners with `border-radius` **never a raw `clip-path`/`mask` chamfer**
52
+ (it shears off borders, shadows, and content near the cut and re-breaks on
53
+ every padding/value change: the recurring "cut corners" defect). A genuinely
54
+ ornamented or angular frame belongs in chrome, generated. The one
55
+ load-bearing `mask`/`clip-path` in this skill is the masked-fill reveal
56
+ below never corner shaping.
50
57
  - **Chrome (sprites — what THIS pipeline generates).** Opaque frames, corner
51
58
  brackets, ornaments, emblems, icons, medallions — hard-alpha art laid over
52
59
  the glass. The Stage-2 sheet contains ONLY chrome; never a panel with its
@@ -73,7 +80,7 @@ ring) that no hand-written CSS can fake.
73
80
  the container is the sprite and the content is DOM — the default split.
74
81
  - **Rule B — image vs code (the 5-minute test).** If CSS can build the
75
82
  element in under ~5 minutes without losing fidelity — flat plates, simple
76
- dots/pips, plain strokes, a chamfered corner — it is CSS. If it needs
83
+ dots/pips, plain strokes, a rounded corner — it is CSS. If it needs
77
84
  craft (material, ornament, painterly texture), it is a sprite. One
78
85
  exception cuts the other way: a static label engraved/embossed into a
79
86
  frame's craft stays BAKED in the sprite (`baked_static` below) even
@@ -81,9 +88,10 @@ ring) that no hand-written CSS can fake.
81
88
  - **CSS-chrome recipes — the middle ground.** Between bare CSS and the full
82
89
  sprite pipeline sits brief-styled CSS chrome, right for utilitarian panels
83
90
  and secondary screens: a two-hue gradient frame (border + inset
84
- box-shadow in brief hues), a clip-path chamfer (execution bar per
85
- `$genex-threejs-game-ui`'s corner rules), a 9-slice-ish panel from nested
86
- divs (outer div = border hue, inset div = plate hue, 2–3px reveal). What
91
+ box-shadow in brief hues), rounded with `border-radius` (never a raw
92
+ `clip-path` chamfer — see `$genex-threejs-game-ui`'s corner rules), a
93
+ 9-slice-ish panel from nested divs (outer div = border hue, inset div =
94
+ plate hue, 2–3px reveal). What
87
95
  makes these legitimate: **CSS elements are styled FROM THE BRIEF — hue,
88
96
  weight, texture. Default-gray CSS anywhere on screen is a defect** (the
89
97
  classic failure: score pips shipped as unstyled "○ ●" system glyphs).
@@ -105,7 +113,10 @@ The HUD overlays a **live 3D scene**. The pixels between widgets show the game.
105
113
  burns generations.
106
114
  - **Every widget needs internal contrast** — a panel fill, outline stroke, or
107
115
  semi-opaque backdrop — because the scene behind it might be a snowfield or a
108
- torchlit dungeon. Pure-outline widgets over arbitrary scenes fail.
116
+ torchlit dungeon. Pure-outline widgets over arbitrary scenes fail. An opaque
117
+ frame sprite already IS that contrast; don't stack a plate behind it. A
118
+ needed backdrop lives inside the widget's silhouette, never as a rectangle
119
+ spilling past the art (see the plate rule under "three layers").
109
120
  - **The mockup must be flat and head-on** — a 2D screen-space overlay, never
110
121
  tilted, isometric, or in perspective. Angled panels have no clean silhouette
111
122
  and deconstruct into skewed slices. If the mockup comes back tilted,
@@ -203,13 +214,26 @@ and [references/stage2-prompt-template.md](references/stage2-prompt-template.md)
203
214
  gate — the Stage-1 mockup IS the game concept.** There is no separate
204
215
  UI-free concept image before it: this one generation carries scene + HUD,
205
216
  serves as the user's style checkpoint, and anchors all later art
206
- (`$genex-threejs-game-ui` owns the checkpoint choreography). The scene half
217
+ (`$genex-threejs-game-ui` owns the checkpoint choreography). **Stage 2 onward
218
+ is frame-anchored, so it waits for the user's keep/change YES on that
219
+ checkpoint — kick it off the instant they approve, not before.** The scene half
207
220
  of the prompt is written in TEXT from the game plan — `[GAME_SCENE]` in the
208
- Stage-1 template: setting, the moment, what the player is doing, lighting
209
- never `--edit`-anchored to a prior image (there is none). Then write the
221
+ Stage-1 template: setting, the moment, what the player is doing, lighting.
222
+ **If a concept/reference image already exists the user's own concept art, or
223
+ a look frame you generated and they approved — anchor Stage 1 to it with
224
+ `--edit <that-url>` so the HUD inherits its exact palette, materials, and
225
+ lighting; that anchoring is what makes the final HUD actually match the
226
+ concept, and skipping it is why a text-only mockup drifts.** A text-only
227
+ Stage 1 is the fallback for when no reference exists. (To `--edit` a
228
+ user-supplied image it must be reachable as an R2 URL — a bare chat attachment
229
+ is not; reproduce it as a generation first, or say plainly you can't anchor to
230
+ it rather than silently guessing.) Then write the
210
231
  widget layout and wiring code (placement, masked-fill scaffolding,
211
232
  plain-CSS placeholder bars) while it renders — the CSS HUD keeps
212
- the game playable until the sprites land. The mockup is a STYLE anchor
233
+ the game playable until the sprites land. When a sprite lands it REPLACES
234
+ its placeholder: delete the placeholder's own background/plate as you wire
235
+ the opaque frame in — a placeholder dark div left behind an opaque sprite
236
+ is the black-box defect. The mockup is a STYLE anchor
213
237
  only: the widget set and
214
238
  layout come from the element inventory and the game contract — a mechanic
215
239
  the mockup invented (a lap counter, a stamina orb) does not enter the HUD,
@@ -221,8 +245,9 @@ re-run from Stage 1 with their notes — cheaper than it sounds (one image
221
245
  is the whole concept now), so say so in one line and do it.
222
246
 
223
247
  ```bash
224
- # Stage 1 — the game CONCEPT: full HUD composited over the game's own scene,
225
- # described in TEXT (one generation covers concept + layout reference).
248
+ # Stage 1 — the game CONCEPT: full HUD composited over the game's own scene.
249
+ # TEXT-described; add `--edit <concept-url>` to anchor it to an existing
250
+ # concept/reference image so the HUD inherits its exact style.
226
251
  # Two candidates in one call; pick the better, save its URL:
227
252
  npx genex image "<filled stage-1 prompt>" --size 2560x1440 --quality high --candidates 2
228
253
 
@@ -311,11 +336,13 @@ To fix ONE sprite, don't re-run the pipeline:
311
336
  painted ON TOP. Never put the gradient on the scaling fill itself — its `%`
312
337
  stops anchor to the fill's own width and the segments visibly compress as
313
338
  the value drops.
314
- - **Every numeric readout gets a backing plate** a
315
- `background: rgba(10, 8, 6, 0.6)` rounded div behind the text by default, an
339
+ - **A numeric readout sitting as bare text over the scene gets a backing
340
+ plate** — a `background: rgba(10, 8, 6, 0.6)` rounded div behind the text, an
316
341
  extracted plate sprite when the art has one, or a deliberate skip documented
317
342
  in a comment (`/* no plate — reads against <reason> */`). Bare digits look
318
- fine over the dev background and vanish over a bright scene.
343
+ fine over the dev background and vanish over a bright scene. A readout
344
+ already sitting on an opaque frame sprite needs NO separate plate — the frame
345
+ backs it; a second dark div only boxes it (the no-double-backing rule above).
319
346
 
320
347
  ## Discrete vs continuous — classify every meter before Stage 2
321
348
 
@@ -473,6 +500,13 @@ Two hard rules:
473
500
 
474
501
  ## Verify before calling it done
475
502
 
503
+ **This is a gate, not a suggestion — the HUD is not "done", does not ship, and
504
+ is not called a milestone until you have looked at it running.** The field
505
+ failure is an agent that wires the sprites, takes one glance, notices a possible
506
+ clip or cutoff, and ships anyway with "I'll refine if the user reports issues."
507
+ That punt IS the defect. You are the one who verifies, not the user. If you are
508
+ out of budget to check, say the HUD is unverified — never call it done.
509
+
476
510
  - **Open every extracted PNG and eyeball it** — right subject, right name.
477
511
  The reading-order sort can mismap when rows are uneven; re-run `extract`
478
512
  with corrected name order rather than regenerating anything.
@@ -483,9 +517,11 @@ Two hard rules:
483
517
  Display fonts run 25–50% wider than a naive estimate; widen the box or drop
484
518
  the weight, don't shrink the font.
485
519
  - **No widget overlaps** at the reference viewport, and none off-screen.
486
- - **Screenshot-check over real gameplay** — bright AND dark scenes — with
487
- `$genex-threejs-visual-validation`'s capture discipline. Judge the pixels,
488
- not your intent.
520
+ - **Screenshot the running HUD and inspect it** — bright AND dark scenes —
521
+ with `$genex-threejs-visual-validation`'s capture discipline. **Zoom every
522
+ corner:** no sheared frames, no clipped/cut corners, no text truncated or
523
+ colliding with an edge. Judge the pixels, not your intent — skipping this is
524
+ exactly how the "cut corners" defect ships.
489
525
 
490
526
  ## The closing wiring audit — the pipeline's final stage, not optional
491
527
 
@@ -517,6 +553,16 @@ does not count. fillBox numbers, `segments`, and real sprite dims do not
517
553
  survive paraphrase; the canonical version of this failure is a subagent
518
554
  reporting "masks validated" while the parent ships width% fills.
519
555
 
556
+ **Do not fire-and-forget the whole HUD/menu/logo into one background subagent.**
557
+ The field failure: the entire production art pipeline was handed to a single
558
+ background agent that then stalled overnight, so every preview the user played
559
+ showed the CSS placeholder and the real HUD only landed hours later, unseen. If
560
+ art runs in a subagent it is a BOUNDED task you wait on, then wire and verify in
561
+ THIS session — not an overnight handoff. While the art is pending the HUD is
562
+ unverified: do not call the game done or push it as a finished milestone. If a
563
+ subagent stalls or misses its window, wire what landed and say plainly what is
564
+ still placeholder — never present a placeholder HUD as the finished look.
565
+
520
566
  ## Cost & latency honesty
521
567
 
522
568
  A full HUD is **~9 image generations** (mockup + deconstruct + clean + a few
@@ -173,8 +173,10 @@ Pause both videos when the menu phase hides (they're decode work), and
173
173
  resume the front one when it returns. Watch ONE full cycle in the browser
174
174
  before calling the menu done — that's the seam check.
175
175
 
176
- **Work async — the menu must never block the game.** Generate the frame right
177
- after the UI plan gate, enqueue the video with `--no-wait`, ship the CSS menu
176
+ **Work async — the menu must never block the game.** The still is
177
+ `--edit`-anchored to the concept mockup, so it's style-dependent: generate it
178
+ the moment the user approves the concept (not before), enqueue the video with
179
+ `--no-wait`, ship the CSS menu
178
180
  (buttons + title over the frame IMAGE as a static backdrop), and swap the
179
181
  `<video>` in when `genex wait` prints the URL. The frame image doubles as the
180
182
  **loading screen background** — it exists minutes before the video does (see
@@ -368,13 +370,14 @@ not):
368
370
  re-anchored the look) re-opens this rule once — re-edit the still
369
371
  (`--edit` against the new concept mockup) and re-run the video from the
370
372
  new still. Agent-initiated polish never does.
371
- - **1080p is the defaultleave it.** The menu clip is full-screen key art,
372
- the first moving thing the player sees, and 720p visibly softens it on any
373
- modern display. `genex video` renders 1080p by default; pass
374
- `--resolution 720p` only as an explicit cost fallback (about half the
375
- credits) when the user asks to economize. The flip side of 1080p: getting
376
- the phone rule above wrong now costs twice as much bandwidth poster on
377
- phone tiers, always.
373
+ - **The menu (`--frame`) path renders 720p don't force 1080p.** The
374
+ frame-conditioned first-last-frame route was rejected by the provider at
375
+ 1080p (repeated 422 failures that shipped a dead static menu), so the menu
376
+ video path defaults to the proven-good, seamless-looping 720p no flag
377
+ needed. The DOM crossfade + full-screen scale read fine at 720p; do NOT pass
378
+ `--resolution 1080p` on a `--frame` menu clip just to sharpen it, or you risk
379
+ re-triggering the 422 and losing the video entirely. (Plain text-to-video,
380
+ without `--frame`, still defaults to 1080p.)
378
381
  - **Pause/victory/defeat variants reuse the same video — as GRADES.** Same
379
382
  `<video>` element or URL, different emotion via CSS `filter` on the
380
383
  background: pause = a plain dark overlay (`rgba(0,0,0,0.55)`); defeat =
@@ -404,9 +407,11 @@ not):
404
407
  genuine state change; expect a loop seam.
405
408
  - `--duration <sec>` (video) — 4, 6, or 8 for frame-conditioned clips;
406
409
  default 8.
407
- - `--resolution <720p|1080p>` (video) — default 1080p; `720p` is the
408
- explicit cost fallback (~half the credits). Loop clips (`--loop`) ignore
409
- it (that model has no resolution parameter).
410
+ - `--resolution <720p|1080p>` (video) — plain text-to-video defaults to 1080p;
411
+ the frame-conditioned (`--frame`) menu path defaults to **720p** for
412
+ reliability (1080p was rejected there). `720p` is also the cost fallback
413
+ (~half the credits). Loop clips (`--loop`) ignore it (that model has no
414
+ resolution parameter).
410
415
  - `--aspect 16:9 --quality high` (image) — the right settings for a menu frame.
411
416
  - `--no-wait` — enqueue and return immediately with the generation id; pick
412
417
  the result up later with `npx genex wait <id>` (safe to re-run — it attaches
@@ -67,11 +67,12 @@ side — reads as broken at a glance.
67
67
 
68
68
  Load through the quality kit's decoder-wired loader and the model rung ladder
69
69
  (`$genex-threejs-adaptive-quality`; `npx genex controller quality` installs
70
- it). The bare URL is the provider-raw original — multiple 1–4K PBR textures +
71
- dense geometry; phones load the `@1024` rung instead (textures budgeted to
72
- 1024, meshopt-compressed, simplified) and KTX2-capable games get the
73
- GPU-compressed sibling. Desktop always loads the original. R2 sends the right
74
- CORS headers, so cross-origin loading just works:
70
+ it). The bare URL is the provider-raw original — ~500k triangles + several 4K
71
+ PBR textures, archival/remix source, NOT a game asset (31 raw props once
72
+ floored an M4 Max). Every model ships a game-ready ladder instead: desktop
73
+ tiers load the `@2048` rung, phones the `@1024` rung (both meshopt-compressed
74
+ and simplified), and KTX2-capable games get the GPU-compressed sibling. R2
75
+ sends the right CORS headers, so cross-origin loading just works:
75
76
 
76
77
  ```ts
77
78
  import { createGltfLoader } from "./controllers/quality/gltf-loader.ts";
@@ -86,6 +87,22 @@ const gltf = await loadModelWithFallback(
86
87
  MODEL_URL, tier, (u) => gltfLoader.loader.loadAsync(u), { ktx2: gltfLoader.ktx2 },
87
88
  );
88
89
  const model = gltf.scene;
90
+ // Tame ONLY the provider's mirror-metal artifact (metalness≈1 + near-zero
91
+ // roughness mirrors the whole sky env and swims with every camera move). Do
92
+ // NOT flatten every material to 0.6 — that was dulling legitimately metallic
93
+ // props (gunmetal, chrome, gold, polished stone). Clamp the extreme case only;
94
+ // leave everything else as authored.
95
+ model.traverse((o) => {
96
+ const m = (o as THREE.Mesh).material as THREE.MeshStandardMaterial;
97
+ if (!m?.isMeshStandardMaterial) return;
98
+ if (m.metalness > 0.85 && m.roughness < 0.2) {
99
+ m.metalness = 0.7; // still reads metallic, no longer a full mirror
100
+ m.roughness = Math.max(m.roughness, 0.3);
101
+ }
102
+ m.envMapIntensity = Math.min(m.envMapIntensity, 0.8);
103
+ });
104
+ // If a model that SHOULD read metallic still looks dull, this clamp is not the
105
+ // cause — check the model against the concept and lift per-material as needed.
89
106
  model.scale.setScalar(1); // tune to taste
90
107
  model.position.set(0, 0, 0);
91
108
  scene.add(model);
@@ -100,8 +117,42 @@ Never swallow a failed load silently: if you wrap a generated-asset load in a
100
117
  `catch`, `console.warn` the asset name in it — your own self-check reads the
101
118
  console, and a bare `.catch(() => null)` hides a missing model from you too.
102
119
 
103
- To place many copies, `model.clone()` per instance. For animated GLBs, drive
104
- `gltf.animations` with a `THREE.AnimationMixer`.
120
+ **Copies**: one or two, `model.clone()` (clones share geometry/textures on the
121
+ GPU). **Three or more of the same model → `InstancedMesh`** clones still cost
122
+ one draw call per mesh per copy; instancing renders all copies in one draw
123
+ call per source mesh:
124
+
125
+ ```ts
126
+ // placements: THREE.Matrix4[] — one world transform per copy
127
+ const group = new THREE.Group();
128
+ gltf.scene.updateMatrixWorld(true);
129
+ const tmp = new THREE.Matrix4();
130
+ gltf.scene.traverse((o) => {
131
+ const src = o as THREE.Mesh;
132
+ if (!src.isMesh) return;
133
+ const im = new THREE.InstancedMesh(src.geometry, src.material, placements.length);
134
+ placements.forEach((p, i) => im.setMatrixAt(i, tmp.multiplyMatrices(p, src.matrixWorld)));
135
+ im.instanceMatrix.needsUpdate = true;
136
+ im.castShadow = im.receiveShadow = true;
137
+ group.add(im);
138
+ });
139
+ scene.add(group);
140
+ ```
141
+
142
+ **Distance LOD** (open worlds / long sightlines): the rung ladder already ships
143
+ a lighter mesh — use the phone rung as the far level. One extra fetch; worth it
144
+ past ~25 m draw ranges, skip it in small arenas:
145
+
146
+ ```ts
147
+ const far = await gltfLoader.loader.loadAsync(`${MODEL_URL}@1024`);
148
+ const lod = new THREE.LOD();
149
+ lod.addLevel(gltf.scene, 0); // near: the tier's rung
150
+ lod.addLevel(far.scene, 25); // far: phone rung — three swaps by camera distance
151
+ scene.add(lod);
152
+ ```
153
+
154
+ For animated GLBs, drive `gltf.animations` with a `THREE.AnimationMixer`
155
+ (animated/skinned meshes can't instance — clone those).
105
156
 
106
157
  **If the game has physics, give the model a collider** — a GLB added only to the
107
158
  scene is a ghost: players and objects pass straight through it.