@genex-ai/cli-demo 1.5.1-dev.396 → 1.5.2-dev.398

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -985,6 +985,7 @@ async function exists(p) {
985
985
 
986
986
  // src/lib/agents-contract.ts
987
987
  import fs6 from "fs/promises";
988
+ import os4 from "os";
988
989
  import path6 from "path";
989
990
  var CONTRACT_BEGIN = "<!-- genex:contract:begin (managed by genex \u2014 edits inside this block are overwritten on sync) -->";
990
991
  var CONTRACT_END = "<!-- genex:contract:end -->";
@@ -996,7 +997,7 @@ Your agentic capabilities that can boost your creation (all via \`npx genex \u20
996
997
  Important note: put soul into your creations, with many details and love. Aim to make them realistic and feel real. Use genex capabilities as an extension, but never limit your imagination \u2014 you are a powerful agent. Use your built-in sub-agents and iteration loops (\`/loop\` or your platform's equivalent) where you need them to reach outstanding results. Verify yourself. Anything you put in front of the player should already feel alive and impressive \u2014 never a bare scene waiting for "later". Build what's still missing before polishing what already works.
997
998
 
998
999
  1. Load the \`genex-game-director\` skill before starting the requested work. Route from the player's latest clear request: a focused request starts directly without replaying discovery or commissioning unrelated lanes. After ANY context compaction or session resume, re-read this file and \`DESIGN.md\`, re-load the skill for the stage you are executing, and continue from the Build plan's \`Now:\` line \u2014 never from memory alone.
999
- 2. Ask only when one unresolved answer materially changes the work. If the request is clear, do not repeat an interview, confirm the pitch, force a concept round, or ask whole-game-vs-one-part again. For a genuinely broad new game, ask one decision: whole coordinated build or one request-relevant part first. If there is no clear request \u2014 a setup prompt pasted with nothing of their own \u2014 ask in PLAIN CHAT what they want to make, in their own words, and never fill the blank by pitching concepts. That opening question stays in chat on purpose: their reply carries the whole request, including anything they say about HOW you should work, and a menu of options answers a narrower question than the one they need to answer. Once you know what you are building, ask one decision at a time with your built-in question tool with clickable answer options when you have one; use a short plain-chat question otherwise. If the player stays silent after a necessary question, proceed on stated assumptions where reasonable and record each as "assumed \u2014 player didn't answer" in DESIGN.md \u2192 Decisions.
1000
+ 2. Ask only when one unresolved answer materially changes the work. If the request is clear, do not repeat an interview, confirm the pitch, force a concept round, or ask whole-game-vs-one-part again. For a genuinely broad new game, ask one decision: whole coordinated build or one request-relevant part first. If there is no clear request \u2014 a setup prompt pasted with nothing of their own \u2014 ask in PLAIN CHAT what they want to make, in their own words, and never fill the blank by pitching concepts. Once you know what you are building, ask one decision at a time with your built-in question tool with clickable answer options when you have one; use a short plain-chat question otherwise. If the player stays silent after a necessary question, proceed on stated assumptions where reasonable and record each as "assumed \u2014 player didn't answer" in DESIGN.md \u2192 Decisions.
1000
1001
  3. \`DESIGN.md\` at the project root is the durable design contract AND build plan. Keep three truths distinct: the player's requested outcome, the current \`Now:\` focus, and open commitments. It must carry a \`## Build plan & status\` section with the working mode (\`whole coordinated build\`, \`step by step\`, or \`focused change\`), numbered milestones with status marks, and a \`Now:\` line naming the current one \u2014 a milestone is done only when its work reached a preview. The latest clear request may replace \`Now:\` immediately; it never silently shrinks the requested outcome or deletes unrelated commitments. Keep every decision, assumption, generation id/URL, local output, and wiring state current. When the plan first lands, tell the player in one plain line that you recorded what they asked for in \`DESIGN.md\` and will keep it current.
1001
1002
  4. Use \`genex\` commands to generate art, audio, video, characters, and UI. A local reference image is not a reason to switch tools: pass its file path to genex (\`--edit\` and \`--inpaint\` accept local paths). Don't limit yourself: add details, and mix generated assets with procedural generation to make scenes and games detailed and lively.
1002
1003
  5. When shipping your game / project: building, previewing, and publishing go only through \`genex preview\` / \`genex publish\` \u2014 never load your platform's own site-building, hosting, or deploy skills for this game.
@@ -1017,13 +1018,13 @@ Important note: put soul into your creations, with many details and love. Aim to
1017
1018
  ${CONTRACT_END}
1018
1019
  `;
1019
1020
  var CLAUDE_IMPORT_LINE = "@AGENTS.md";
1021
+ var CONTRACT_REGION = /<!-- genex:contract:begin[^\n]*-->[\s\S]*?<!-- genex:contract:end -->/;
1020
1022
  function mergeContractBlock(existing) {
1021
1023
  const block = GENEX_CONTRACT_BLOCK.trimEnd();
1022
1024
  if (existing === null || existing.trim() === "") return `${block}
1023
1025
  `;
1024
- const region = /<!-- genex:contract:begin[^\n]*-->[\s\S]*?<!-- genex:contract:end -->/;
1025
- if (region.test(existing)) {
1026
- return existing.replace(region, block);
1026
+ if (CONTRACT_REGION.test(existing)) {
1027
+ return existing.replace(CONTRACT_REGION, block);
1027
1028
  }
1028
1029
  const cleaned = existing.split("\n").filter(
1029
1030
  (line) => !line.includes("genex:contract:begin") && !line.includes("genex:contract:end")
@@ -1071,10 +1072,75 @@ ${CLAUDE_IMPORT_LINE}
1071
1072
  }
1072
1073
  return changed;
1073
1074
  }
1075
+ async function healAncestorContracts(projectDir, stopDir = os4.homedir()) {
1076
+ const findings = [];
1077
+ const stop = path6.resolve(stopDir);
1078
+ let dir = path6.dirname(path6.resolve(projectDir));
1079
+ for (let depth = 0; depth < 20; depth++) {
1080
+ try {
1081
+ let isProject = false;
1082
+ try {
1083
+ await fs6.access(path6.join(dir, ".genex", "project.json"));
1084
+ isProject = true;
1085
+ } catch {
1086
+ }
1087
+ if (!isProject) {
1088
+ const agentsPath = path6.join(dir, "AGENTS.md");
1089
+ let content = null;
1090
+ try {
1091
+ content = await fs6.readFile(agentsPath, "utf8");
1092
+ } catch {
1093
+ }
1094
+ if (content !== null && CONTRACT_REGION.test(content)) {
1095
+ let remainder = content;
1096
+ while (CONTRACT_REGION.test(remainder)) {
1097
+ remainder = remainder.replace(CONTRACT_REGION, "");
1098
+ }
1099
+ if (remainder.trim() === "") {
1100
+ await fs6.unlink(agentsPath);
1101
+ try {
1102
+ const claudePath = path6.join(dir, "CLAUDE.md");
1103
+ if ((await fs6.readFile(claudePath, "utf8")).trim() === CLAUDE_IMPORT_LINE) {
1104
+ await fs6.unlink(claudePath);
1105
+ }
1106
+ } catch {
1107
+ }
1108
+ findings.push({ dir, removed: true });
1109
+ } else {
1110
+ findings.push({ dir, removed: false });
1111
+ }
1112
+ }
1113
+ }
1114
+ } catch {
1115
+ }
1116
+ if (dir === stop) break;
1117
+ const parent = path6.dirname(dir);
1118
+ if (parent === dir) break;
1119
+ dir = parent;
1120
+ }
1121
+ return findings;
1122
+ }
1123
+ async function healAncestorContractsAndReport(log, projectDir) {
1124
+ try {
1125
+ for (const f of await healAncestorContracts(projectDir)) {
1126
+ const file = path6.join(f.dir, "AGENTS.md");
1127
+ if (f.removed) {
1128
+ log.plain(
1129
+ `\u{1F9F9} Removed a stray genex build contract from ${file} \u2014 it belongs in each game's folder, and a stale copy above the project misleads agents.`
1130
+ );
1131
+ } else {
1132
+ log.warn(
1133
+ `A stale genex build contract sits inside ${file} next to your own text. Agents read every parent folder's AGENTS.md, so it conflicts with this game's contract \u2014 delete the block between the genex:contract markers there.`
1134
+ );
1135
+ }
1136
+ }
1137
+ } catch {
1138
+ }
1139
+ }
1074
1140
 
1075
1141
  // src/lib/updates.ts
1076
1142
  import fs7 from "fs/promises";
1077
- import os4 from "os";
1143
+ import os5 from "os";
1078
1144
  import path7 from "path";
1079
1145
  function parseSemver(v) {
1080
1146
  const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
@@ -1179,7 +1245,7 @@ async function syncSkillsForTarget(target, templatesDir = getTemplatesDir(), ver
1179
1245
  }
1180
1246
  async function cleanupLegacyGlobalSkills(log) {
1181
1247
  try {
1182
- const home = os4.homedir();
1248
+ const home = os5.homedir();
1183
1249
  const [realCwd, realHome] = await Promise.all([
1184
1250
  fs7.realpath(process.cwd()).catch(() => path7.resolve(process.cwd())),
1185
1251
  fs7.realpath(home).catch(() => path7.resolve(home))
@@ -1235,10 +1301,11 @@ async function syncSkills(log) {
1235
1301
  }
1236
1302
  if (refreshed) log.plain(`\u{1F504} Genex skills updated to ${version}`);
1237
1303
  try {
1238
- await fs7.access(path7.join(process.cwd(), ".genex"));
1304
+ await fs7.access(path7.join(process.cwd(), ".genex", "project.json"));
1239
1305
  if (await writeAgentsContract(process.cwd())) {
1240
1306
  log.plain("\u{1F504} Genex build contract refreshed (AGENTS.md managed block)");
1241
1307
  }
1308
+ await healAncestorContractsAndReport(log, process.cwd());
1242
1309
  } catch {
1243
1310
  }
1244
1311
  } catch {
@@ -1702,6 +1769,7 @@ async function runInit(opts) {
1702
1769
  if (await writeAgentsContract(process.cwd())) {
1703
1770
  log.dim(" wrote the Genex build contract into AGENTS.md (managed block) + CLAUDE.md stub");
1704
1771
  }
1772
+ await healAncestorContractsAndReport(log, process.cwd());
1705
1773
  const apiUrl = getApiUrl(opts.apiUrl);
1706
1774
  const echoIdentity = async () => {
1707
1775
  const email = await fetchSignedInEmail(apiUrl, token);
@@ -2145,7 +2213,7 @@ function relTime(iso) {
2145
2213
  import { spawn as spawn3 } from "child_process";
2146
2214
  import crypto3 from "crypto";
2147
2215
  import fs15 from "fs/promises";
2148
- import os5 from "os";
2216
+ import os6 from "os";
2149
2217
  import path14 from "path";
2150
2218
 
2151
2219
  // ../../packages/mobile-scan/src/image-dims.ts
@@ -4688,7 +4756,7 @@ async function pushWorktree(cwd, pushUrl, managed, log, branch = "main") {
4688
4756
  log.error("Couldn't save your game's source \u2014 please try again.");
4689
4757
  return false;
4690
4758
  };
4691
- const gitDir = await fs15.mkdtemp(path14.join(os5.tmpdir(), "genex-source-"));
4759
+ const gitDir = await fs15.mkdtemp(path14.join(os6.tmpdir(), "genex-source-"));
4692
4760
  const base = { GIT_DIR: gitDir };
4693
4761
  if (urlHasEmbeddedCredentials(pushUrl)) {
4694
4762
  base.GIT_CONFIG_COUNT = "1";
@@ -5618,7 +5686,7 @@ function surfaceNudges(log, s) {
5618
5686
  }
5619
5687
  for (const d of s.depthRange) {
5620
5688
  log.warn(
5621
- `Camera depth range ${d.detail} (far/near = ${d.ratio.toExponential(1)}) at ${d.where} is past what a 24-bit depth buffer can serve \u2014 touching surfaces will z-fight and flicker, and it reads fine right up until it doesn't. near is the expensive knob (resolvable depth \u2248 z\xB2 / (near \xD7 2^24)), so raise near to the closest the camera can actually get. If the range is genuinely astronomical, that is what WebGLRenderer's logarithmicDepthBuffer is for: genex-threejs-camera-direction.`
5689
+ `Camera depth range ${d.detail} (far/near = ${d.ratio.toExponential(1)}) at ${d.where} is past what a 24-bit depth buffer can serve \u2014 touching surfaces will z-fight and flicker, and it reads fine right up until it doesn't. near is the expensive knob (resolvable depth \u2248 z\xB2 / (near \xD7 2^24)), so raise near to the closest the camera can actually get \u2014 the quality kit's cameraNearFar derives the pair from the world, and spreading depthRendererOptions() into the renderer adds reversed-Z (free precision at distance, works on WebGL and WebGPU alike): genex-threejs-adaptive-quality. If the range is genuinely astronomical, that is what logarithmicDepthBuffer is for (it costs early-Z): genex-threejs-camera-direction.`
5622
5690
  );
5623
5691
  }
5624
5692
  }
@@ -16581,6 +16649,8 @@ var CONTROLLER_FILE_SETS = {
16581
16649
  "quality/governor.ts",
16582
16650
  "quality/pick-asset.ts",
16583
16651
  "quality/gltf-loader.ts",
16652
+ "quality/depth.ts",
16653
+ "quality/context-loss.ts",
16584
16654
  NOTICE
16585
16655
  ],
16586
16656
  // KTX2Loader's basis transcoder (js+wasm) — loaded by PATH at runtime, so
@@ -16589,7 +16659,8 @@ var CONTROLLER_FILE_SETS = {
16589
16659
  skill: "genex-threejs-adaptive-quality",
16590
16660
  sketch: [
16591
16661
  `const tier = detectTier(); // phone-low | phone | desktop-low | desktop \u2014 manual Quality setting wins`,
16592
- `const renderer = new THREE.WebGLRenderer({ antialias: rendererAntialias(tier, /* willRunPost */ true) });`,
16662
+ `const renderer = new THREE.WebGLRenderer({ antialias: rendererAntialias(tier, /* willRunPost */ true), ...depthRendererOptions() }); // WebGPURenderer takes the same options`,
16663
+ `const { near, far } = cameraNearFar({ closest: 1.5, farthest: 800 }); // derive the camera's depth range from the world, never 0.1/1000 from a docs example`,
16593
16664
  `const gltf = createGltfLoader(renderer); // meshopt + KTX2 decoders; model = await loadModelWithFallback(MODEL_URL, tier, (u) => gltf.loader.loadAsync(u), { ktx2: gltf.ktx2 })`,
16594
16665
  `const gov = new QualityGovernor(tier, { setDprScale: (m) => renderer.setPixelRatio(Math.min(window.devicePixelRatio, tier.dprCap * m)), setShadowQuality: (l) => sun.shadow.mapSize.setScalar(l === 'full' ? tier.shadowMapSize : tier.shadowMapSize / 2) }, renderer);`
16595
16666
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "1.5.1-dev.396",
3
+ "version": "1.5.2-dev.398",
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": {
@@ -0,0 +1,76 @@
1
+ // Genex adaptive-quality: GPU context-loss guard (renderer-neutral).
2
+ //
3
+ // Phones revoke GPU contexts under memory pressure and on backgrounding;
4
+ // desktops on driver resets. A game that keeps rendering into a lost context
5
+ // reads as frozen — and the recovery dance is fiddly enough that every game
6
+ // used to re-derive it by hand: the WebGL `webglcontextlost` event MUST be
7
+ // preventDefault()ed or the browser never fires `webglcontextrestored` at
8
+ // all, while WebGPU has no events — it exposes a one-shot `device.lost`
9
+ // promise instead, and a lost WebGPU device does not come back (recovery is
10
+ // re-initialising, in practice a reload the game offers the player).
11
+ //
12
+ // Renderer-neutral like depth.ts: structural types, no THREE import — pass
13
+ // either a WebGLRenderer or a WebGPURenderer.
14
+
15
+ export interface ContextLossHandlers {
16
+ /** Pause the game: stop the loop and audio, show a "restoring…" line. */
17
+ pause: () => void;
18
+ /** Resume after restore — three re-uploads GPU resources on the next
19
+ * frame, so nothing else to rebuild. WebGL only: a lost WebGPU device
20
+ * never restores (see onWebGpuLost). */
21
+ resume: () => void;
22
+ /** WebGPU only, optional: called after `device.lost` (pause() has already
23
+ * run). Default: nothing — the paused "restoring…" state stands, and a
24
+ * reasonable game offers a reload. */
25
+ onWebGpuLost?: () => void;
26
+ }
27
+
28
+ /**
29
+ * Attach loss/restore handling to whichever renderer the game built.
30
+ * Returns a detach function (call it if the game tears the renderer down).
31
+ *
32
+ * attachContextLossGuard(renderer, {
33
+ * pause: () => (running = false),
34
+ * resume: () => (running = true),
35
+ * });
36
+ */
37
+ export function attachContextLossGuard(
38
+ renderer: {
39
+ domElement?: HTMLCanvasElement;
40
+ isWebGPURenderer?: boolean;
41
+ backend?: { device?: { lost?: Promise<unknown> } };
42
+ },
43
+ handlers: ContextLossHandlers,
44
+ ): () => void {
45
+ const canvas = renderer.domElement;
46
+ let detached = false;
47
+
48
+ // WebGPU: one-shot promise, available once the renderer has init()ed.
49
+ // Guarded chaining — on a WebGL renderer none of these keys exist.
50
+ const lost = renderer.isWebGPURenderer ? renderer.backend?.device?.lost : undefined;
51
+ if (lost && typeof lost.then === "function") {
52
+ lost.then(() => {
53
+ if (detached) return;
54
+ handlers.pause();
55
+ handlers.onWebGpuLost?.();
56
+ });
57
+ return () => {
58
+ detached = true;
59
+ };
60
+ }
61
+
62
+ // WebGL: DOM events on the canvas. preventDefault() on the lost event is
63
+ // the load-bearing line — without it the restored event never fires.
64
+ const onLost = (event: Event): void => {
65
+ event.preventDefault();
66
+ handlers.pause();
67
+ };
68
+ const onRestored = (): void => handlers.resume();
69
+ canvas?.addEventListener("webglcontextlost", onLost);
70
+ canvas?.addEventListener("webglcontextrestored", onRestored);
71
+ return () => {
72
+ detached = true;
73
+ canvas?.removeEventListener("webglcontextlost", onLost);
74
+ canvas?.removeEventListener("webglcontextrestored", onRestored);
75
+ };
76
+ }
@@ -0,0 +1,96 @@
1
+ // Genex adaptive-quality: depth precision (z-fighting prevention).
2
+ //
3
+ // Z-fighting — surfaces flickering through each other — has exactly two
4
+ // causes, and only one of them lives in this file:
5
+ //
6
+ // 1. A camera range the depth buffer cannot serve. The docs-example
7
+ // `near 0.1, far 1000` gets pasted, the world grows to kilometres, `far`
8
+ // follows and `near` stays — and precision at distance z is roughly
9
+ // z² / (near × 2²⁴), so the flicker starts at the horizon and creeps in.
10
+ // Fixed here: derive the pair from the WORLD (`cameraNearFar`) and spend
11
+ // float precision where big ranges need it (`depthRendererOptions`).
12
+ // 2. Two opaque surfaces genuinely sharing a plane (road quad on ground at
13
+ // the same y, decal flush on a wall). NO renderer setting can fix that —
14
+ // give them a real gap (1–2 mm indoors, ~1 cm at city scale), or make the
15
+ // decal win with `polygonOffset: true, polygonOffsetFactor: -1,
16
+ // polygonOffsetUnits: -1` (+ depthWrite: false for a flat sticker).
17
+ // The visual-validation skill's console scanner finds these pairs.
18
+ //
19
+ // Renderer-agnostic ON PURPOSE: no THREE import, no WebGLRenderer type.
20
+ // `WebGPURenderer` takes the exact same options object — reversed-Z is native
21
+ // there; WebGL self-gates on EXT_clip_control and falls back to the standard
22
+ // buffer with a console warning (three r185 behaviour), so spreading these
23
+ // options is never worse than not spreading them.
24
+
25
+ /**
26
+ * Options to spread into EITHER renderer constructor:
27
+ *
28
+ * new THREE.WebGLRenderer({ antialias: ..., ...depthRendererOptions() })
29
+ * new WebGPURenderer({ ...depthRendererOptions() })
30
+ *
31
+ * Reversed-Z maps near→1/far→0, moving distant geometry — the part that runs
32
+ * out of depth precision first — into the float-dense end of the range. It
33
+ * keeps early-Z rejection (unlike logarithmicDepthBuffer, which disables the
34
+ * exact optimization the phone tiers depend on — reach for log depth only when
35
+ * a single camera genuinely spans planetary ranges, and say so in DESIGN.md).
36
+ *
37
+ * Three's own materials and its `packing.glsl` depth helpers are reversed-Z
38
+ * aware; a third-party post pass doing hand-rolled depth math might not be.
39
+ * Pass `{ depthReadingPost: true }` if the game ships such a pass (SSAO/DoF
40
+ * from outside three's examples, soft particles, custom depth fog) and you
41
+ * haven't verified it under reversed-Z — plain depth is the safe fallback.
42
+ */
43
+ export function depthRendererOptions(opts?: { depthReadingPost?: boolean }): {
44
+ reversedDepthBuffer: boolean;
45
+ } {
46
+ return { reversedDepthBuffer: !opts?.depthReadingPost };
47
+ }
48
+
49
+ export interface DepthRange {
50
+ /** Closest the camera can genuinely get to visible geometry, in metres —
51
+ * ~0.3 first-person, 1–2 third-person/top-down. Never "0.001 to be safe":
52
+ * halving this halves depth precision at EVERY distance in the scene. */
53
+ closest: number;
54
+ /** Farthest visible thing — world edge, sky dome radius, or fog far. */
55
+ farthest: number;
56
+ }
57
+
58
+ /**
59
+ * Derive `near`/`far` from what the game actually shows, instead of copying
60
+ * `0.1 / 1000` from a docs example:
61
+ *
62
+ * const { near, far } = cameraNearFar({ closest: 1.5, farthest: 800 });
63
+ * const camera = new THREE.PerspectiveCamera(60, aspect, near, far);
64
+ *
65
+ * near sits at half the stated approach (margin against a clipped wall when
66
+ * the camera grazes one), far just past the stated edge. The 1e6 warning
67
+ * threshold mirrors the `genex preview` preflight, so the helper and the
68
+ * platform never disagree about what "too wide" means.
69
+ */
70
+ export function cameraNearFar(range: DepthRange): { near: number; far: number } {
71
+ const closest = Math.max(range.closest, 0.01);
72
+ const near = closest / 2;
73
+ const far = Math.max(range.farthest, closest * 10) * 1.05;
74
+ if (far / near > 1e6) {
75
+ console.warn(
76
+ `[genex quality] camera range near ${near} / far ${far} is wider than a plain depth buffer serves — ` +
77
+ `raise 'closest' to what the camera really reaches, or keep reversed-Z on (depthRendererOptions()).`,
78
+ );
79
+ }
80
+ return { near, far };
81
+ }
82
+
83
+ /**
84
+ * Re-range a live camera when the world grows (new zone, bigger arena) —
85
+ * updating `far` alone is how a mid-build world reintroduces the flicker.
86
+ * Structural type on purpose: works for any camera, imports nothing.
87
+ */
88
+ export function applyDepthRange(
89
+ camera: { near: number; far: number; updateProjectionMatrix(): void },
90
+ range: DepthRange,
91
+ ): void {
92
+ const { near, far } = cameraNearFar(range);
93
+ camera.near = near;
94
+ camera.far = far;
95
+ camera.updateProjectionMatrix();
96
+ }
@@ -11,6 +11,7 @@
11
11
  // renderer so KTX2Loader can probe GPU support; skip it and models simply use
12
12
  // the universal @1024 rung.
13
13
  import type * as THREE from "three";
14
+ import type { WebGPURenderer } from "three/webgpu";
14
15
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
15
16
  import { KTX2Loader } from "three/addons/loaders/KTX2Loader.js";
16
17
  import { MeshoptDecoder } from "three/addons/libs/meshopt_decoder.module.js";
@@ -34,7 +35,10 @@ export interface GenexGltfLoader {
34
35
  * false — every load falls back to browser-decodable variants.
35
36
  */
36
37
  export function createGltfLoader(
37
- renderer?: THREE.WebGLRenderer,
38
+ // Either renderer — the kit never assumes WebGL. KTX2Loader.detectSupport
39
+ // takes both; a WebGPU game just constructs the loader after `await
40
+ // renderer.init()` so the backend it probes is the real one.
41
+ renderer?: THREE.WebGLRenderer | WebGPURenderer,
38
42
  opts?: { transcoderPath?: string },
39
43
  ): GenexGltfLoader {
40
44
  const loader = new GLTFLoader();
@@ -17,18 +17,12 @@ the project from memory or replay completed discovery.
17
17
 
18
18
  ## 1. Check capabilities and boundaries once
19
19
 
20
- Look at your available tools:
21
-
22
- - **Question tool:** use it only when this skill says a real unresolved fork
23
- remains; otherwise continue. Without one, ask one short plain-language
24
- question in chat.
25
- - **Sub-agents:** useful for independent workstreams in a whole coordinated
26
- build; never required for focused work.
27
- - **Browser:** use it to inspect the actual result. Without one, ask the player
28
- for the smallest useful visual observation.
29
-
30
- Never claim a capability you did not find, and never stall because one is
31
- missing.
20
+ Check your tools once — question tool, sub-agents, browser — and use what you
21
+ actually found: without a question tool, ask one short plain-language question
22
+ in chat; without a browser, ask the player for the smallest useful visual
23
+ observation. Sub-agents suit independent workstreams in a whole coordinated
24
+ build and are never required for focused work. Never claim a capability you
25
+ did not find, and never stall because one is missing.
32
26
 
33
27
  Your platform may bundle its own image/video generation and site-building,
34
28
  hosting, or deploy skills. **They are not Genex lanes.** All generated art,
@@ -46,7 +46,7 @@ npx genex controller quality
46
46
  ```
47
47
 
48
48
  Installs `src/controllers/quality/{tier.ts, governor.ts, pick-asset.ts,
49
- gltf-loader.ts}` — game-owned code, edit freely — plus the KTX2 basis
49
+ gltf-loader.ts, depth.ts, context-loss.ts}` — game-owned code, edit freely — plus the KTX2 basis
50
50
  transcoder (`basis_transcoder.js` + `.wasm`) into `public/assets/` (loaded by
51
51
  path at runtime; Vite would drop it anywhere else).
52
52
 
@@ -55,22 +55,62 @@ path at runtime; Vite would drop it anywhere else).
55
55
  ```ts
56
56
  import { detectTier, rendererAntialias } from "./controllers/quality/tier.ts";
57
57
  import { QualityGovernor } from "./controllers/quality/governor.ts";
58
+ import { depthRendererOptions, cameraNearFar } from "./controllers/quality/depth.ts";
59
+ import { attachContextLossGuard } from "./controllers/quality/context-loss.ts";
58
60
 
59
61
  const tier = detectTier(); // phone-low | phone | desktop-low | desktop; manual Quality setting wins
60
62
  // Context MSAA is WASTED under an EffectComposer (it multisamples a buffer the
61
63
  // composer never reads — the classic weak-MacBook lag recipe). Games with a
62
- // post stack pass willRunPost=true and get their AA from the composer target:
63
- const renderer = new THREE.WebGLRenderer({ antialias: rendererAntialias(tier, true) });
64
+ // post stack pass willRunPost=true and get their AA from the composer target.
65
+ // The same options object works on WebGPURenderer — the kit assumes no backend:
66
+ const renderer = new THREE.WebGLRenderer({
67
+ antialias: rendererAntialias(tier, true),
68
+ ...depthRendererOptions(), // reversed-Z: distant surfaces stop z-fighting, early-Z kept
69
+ });
64
70
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, tier.dprCap));
71
+ // Camera range comes from the WORLD, never from a docs example (0.1/1000):
72
+ const { near, far } = cameraNearFar({ closest: 1.5, farthest: 800 });
73
+ const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, near, far);
65
74
  // With post: AA comes from the composer's multisampled target instead —
66
75
  const target = new THREE.WebGLRenderTarget(innerWidth, innerHeight, { samples: tier.composerSamples });
67
76
  const composer = new EffectComposer(renderer, target);
77
+ // GPU context loss (phones revoke contexts under memory pressure): pause,
78
+ // resume on restore — the guard owns the fiddly event rules for both backends.
79
+ attachContextLossGuard(renderer, { pause: () => (running = false), resume: () => (running = true) });
68
80
  ```
69
81
 
70
82
  `detectTier()` also demotes WEAK desktops (Intel iGPU MacBooks, old integrated
71
83
  AMD — desktop GPU strings are unmasked, unlike iOS) to `desktop-low`: DPR 1.5,
72
84
  no MSAA, 1024 shadows, light post. The Quality picker still overrides.
73
85
 
86
+ ## Depth precision (z-fighting)
87
+
88
+ Flickering surfaces have two causes; `depth.ts` owns the first and names the
89
+ second:
90
+
91
+ - **Camera range.** Depth precision at distance z is ~`z² / (near × 2²⁴)` —
92
+ `near` is the expensive knob, and the pasted `0.1 / 1000` that silently grows
93
+ to `0.1 / 100000` is how most games get horizon flicker. Derive the pair with
94
+ `cameraNearFar({ closest, farthest })` (closest the camera truly gets; edge of
95
+ the world), and re-range with `applyDepthRange(camera, …)` when the world
96
+ grows. `...depthRendererOptions()` in the renderer constructor adds
97
+ reversed-Z on top: near→1/far→0, so distant geometry lands in the float-dense
98
+ end. Native on WebGPU; on WebGL three gates it on `EXT_clip_control` and
99
+ falls back to the standard buffer with a console line, so spreading it is
100
+ never worse. Three's materials and `packing.glsl` helpers are reversed-aware;
101
+ only a post pass doing hand-rolled depth math (custom SSAO/DoF/soft
102
+ particles) needs verifying — pass `{ depthReadingPost: true }` to opt out
103
+ until you have. `logarithmicDepthBuffer` is NOT the default fix: it disables
104
+ early-Z rejection (the optimization phone survival leans on) — planetary
105
+ ranges only, recorded in DESIGN.md.
106
+ - **Two opaque surfaces sharing a plane** — road quad at the ground's exact y,
107
+ decal flush on a wall. No renderer flag can fix that: give them a real gap
108
+ (1–2 mm indoors, ~1 cm at city scale), or let the decal win with
109
+ `polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1` (+
110
+ `depthWrite: false` for flat stickers). `$genex-threejs-visual-validation`
111
+ ships a console scanner that lists the exact offending pairs;
112
+ `$genex-threejs-procedural-assets` carries the authoring rule.
113
+
74
114
  The tier owns every budget decision: `dprCap` (1.5 on phones — the single
75
115
  biggest framebuffer lever), `antialias` (off on phones; it is fixed at context
76
116
  creation and can never change live), `shadowMapSize` (1024 phone / 2048
@@ -211,12 +251,15 @@ not the player's account. Default Auto.
211
251
  ## WebGPU games
212
252
 
213
253
  The scaffold ships WebGL and stays the default; if this project already uses
214
- `WebGPURenderer`, keep it (never switch renderers mid-project). Context loss
215
- differs: WebGL fires `webglcontextlost` events; WebGPU exposes a
216
- `device.lost` promise attach a handler that pauses the loop and rebuilds,
217
- mirroring the scaffold's WebGL pattern. All tier knobs apply identically
218
- except `antialias` (WebGPU MSAA is per-render-target and CAN change at
219
- runtime).
254
+ `WebGPURenderer`, keep it (never switch renderers mid-project) the kit
255
+ assumes no backend. `...depthRendererOptions()` spreads into its constructor
256
+ identically (reversed-Z is native there, no extension gate), and
257
+ `createGltfLoader(renderer)` accepts it construct the loader after `await
258
+ renderer.init()` so KTX2 probes the real backend. Context loss:
259
+ `attachContextLossGuard` handles both backends (WebGL DOM events; WebGPU's
260
+ one-shot `device.lost`, after which recovery is re-init — offer a reload via
261
+ `onWebGpuLost`). All tier knobs apply identically except `antialias` (WebGPU
262
+ MSAA is per-render-target and CAN change at runtime).
220
263
 
221
264
  ## Failure conditions
222
265
 
@@ -393,8 +393,12 @@ surfaces that touch will fight and flicker. The expensive knob is `near`, not
393
393
  halving of `near` halves precision at every distance. Set `near` to the closest
394
394
  the camera can genuinely get — for a third-person or top-down game that is
395
395
  metres, not centimetres — and `far` to the far edge of the world, and the ratio
396
- takes care of itself. `genex preview` warns when a camera's range is past what a
397
- plain depth buffer can serve.
396
+ takes care of itself. The quality kit's `depth.ts` owns this for ordinary games:
397
+ `cameraNearFar({ closest, farthest })` derives the pair from the world, and
398
+ `...depthRendererOptions()` in the renderer constructor adds reversed-Z — free
399
+ precision at distance on WebGL and WebGPU alike, keeping the early-Z rejection
400
+ that log depth sacrifices (`$genex-threejs-adaptive-quality`). `genex preview`
401
+ warns when a camera's range is past what a plain depth buffer can serve.
398
402
 
399
403
  It prewarms pipelines by temporarily aiming at representative bodies, then
400
404
  restores both position and quaternion in `finally`.
@@ -119,6 +119,19 @@ pass and the object can still read wrong. The fixes:
119
119
  - **Where two sub-assemblies meet, terminate one inside the other's wall
120
120
  thickness** (clearance math at the widest point, including any bow) —
121
121
  butting them leaves a visible reveal from some angle.
122
+ - **No two opaque surfaces share a plane.** A road quad at the ground's exact
123
+ y, a panel flush on a wall, a rug at floor height — coplanar opaque faces
124
+ z-fight, and a still screenshot is the one tool guaranteed to miss it. Give
125
+ every flat member a real offset from the surface beneath (1–2 mm indoors,
126
+ ~1 cm at city scale); a true decal instead wins the depth test with
127
+ `polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1` +
128
+ `depthWrite: false`. Kit pieces butt end to end (faces meeting back-to-back
129
+ are safe) — they never overlap at the same face. Flicker in the DISTANCE on
130
+ clean geometry is the camera's depth range instead: derive near/far with the
131
+ quality kit's `cameraNearFar` and spread `depthRendererOptions()` into the
132
+ renderer (`$genex-threejs-adaptive-quality`). The
133
+ `$genex-threejs-visual-validation` console scanner lists offending pairs by
134
+ name.
122
135
  - **Merging beats instancing for small repeats.** Merge everything static that
123
136
  shares a material into one buffer and instance only genuine crowds — eight
124
137
  moulded ribs cost a whole extra draw call as an `InstancedMesh` and nothing
@@ -1,11 +1,15 @@
1
1
  ---
2
2
  name: genex-threejs-procedural-vfx
3
- description: Author procedural real-time VFX for Genex Three.js games, and decide WHERE effects belong — walk the game's moments one by one, the way the visual-direction gate walks its surfaces. Use for particles, trails, plasma, sparks, shockwaves, impacts, dissolving debris, ability effects, reentry wakes, event-timed visuals, effect pools, HDR emission hierarchy, and gameplay-readable spectacle.
3
+ description: Author procedural real-time VFX for Genex Three.js games, and decide WHERE effects belong — walk the game's moments one by one. Methodology for the cast envelope (travel/impact/fade), GPU-simulated particles, parameter-space filaments (lightning, beams, trails, tendrils), pooled dynamic lights, and impact grammar (shake, flash, shockwaves, aging residue). Use for particles, sparks, plasma, shockwaves, ability effects, event-timed visuals, and gameplay-readable spectacle.
4
4
  ---
5
5
 
6
6
  # Genex Three.js Procedural VFX
7
7
 
8
- Build effects from an event envelope, motion field, geometry representation, and shading response. Avoid independent particle emitters that happen to share a color.
8
+ Build effects from an event envelope, motion field, geometry representation, and
9
+ shading response. Avoid independent particle emitters that happen to share a
10
+ color. Everything below is **method, not library**: the same math works as GLSL
11
+ in a `ShaderMaterial` on WebGL and as TSL nodes on WebGPU — pick the game's
12
+ renderer, keep the method.
9
13
 
10
14
  ## The moment gate — run this BEFORE writing any effect code
11
15
 
@@ -45,14 +49,99 @@ fire. That sentence answers the surface gate correctly and the moment gate not a
45
49
  all. If a primitive is standing in for an effect, it is a placeholder, whatever
46
50
  the comment says.
47
51
 
48
- ## Particles do not render round by default
52
+ ## The law: dice on the CPU, dimensions in the shader
49
53
 
50
- `new THREE.PointsMaterial({ size, color })` draws every point as a **hard-edged
51
- camera-facing square**. There is no soft falloff, no roundness, no fade — those
52
- are things you add. Shipping the default is the "square flying things" look, and
53
- it is the single most common particle defect in real builds.
54
+ The one decision that separates an effect that looks authored from one that
55
+ looks stapled on. When an effect spawns, capture **only what randomness
56
+ decided** unitless fractions along the cast, seeds, timestamps. Never a
57
+ metre, radian, or second. Every dimension is resolved each frame from one live
58
+ config object, and inside the shader wherever possible. Three consequences,
59
+ all load-bearing:
60
+
61
+ - **Effects tune live.** Change a height or spread value and a field that is
62
+ already standing regrows to match — because nothing captured the old number.
63
+ Tuning against a paused frame becomes possible, which is where shapes are
64
+ actually worth judging.
65
+ - **Geometry becomes reusable.** A vertex that carries only `(t, side)` — how
66
+ far along, which edge — serves a bolt of any length, shape, and width. The
67
+ same strip is a lightning filament, a beam coil, a tendril, and a trail.
68
+ - **Nothing goes stale.** There is no CPU-side path or cached transform to
69
+ desynchronize from what the shader draws.
70
+
71
+ Corollary: **geometry carries parameters, shaders compute shape.** The
72
+ procedural look in strong VFX comes almost entirely from evaluating position,
73
+ silhouette, and color per frame from parameters, not from ever-larger meshes.
74
+
75
+ ## Anatomy of a moment: the envelope
76
+
77
+ Every event effect — a cast, an explosion, a landing — shares one envelope,
78
+ and building it once keeps every layer synchronized:
79
+
80
+ - **Phases**: travel → impact → fade. Each phase exposes a normalized 0..1
81
+ progress; every layer reads `(phase, t)` instead of keeping its own scattered
82
+ time constants. Retiming the whole effect is then one number per phase.
83
+ - **The front**: advance in metres/second scaled by delta time, eased off the
84
+ standstill. Key the ease on **elapsed age, not progress** — an ease keyed on
85
+ progress multiplies the very first step by zero and the front never leaves
86
+ the caster.
87
+ - **The local frame**: origin, unit direction, `side = direction × up`. Every
88
+ layer — geometry, particle velocities, decal orientation — places itself in
89
+ this frame, which is what makes secondary motion read as caused by the event
90
+ rather than coincident with it.
91
+ - **Reveal, don't scale.** Build the full shape and clip it at the front's
92
+ progress (discard fragments past `t = progress`, with a bright leading tip).
93
+ A shape that stretches as it extends crawls and swims; one that is revealed
94
+ keeps its character while it grows.
95
+
96
+ ## Particles: spawn on the CPU, simulate in the shader
54
97
 
55
- The fix costs eight lines and no generation call:
98
+ A per-frame CPU particle loop is the naive default: it burns main-thread time
99
+ and caps counts in the hundreds. The method that scales to thousands is
100
+ instanced quads whose motion is **analytic** in the vertex stage:
101
+
102
+ - One instanced quad; per-instance attributes: start position, velocity, spawn
103
+ time, lifetime, size, seed, spin, tint. The CPU writes these **once per
104
+ spawn** into a ring buffer (the cursor wraps, so spamming an ability recycles
105
+ the oldest slots instead of allocating) and uploads only the changed ranges.
106
+ - Position is a pure function of age — exact exponential drag, no integration:
107
+
108
+ ```
109
+ age = now - spawnTime // dead if age < 0 or age > lifetime
110
+ pos = start
111
+ + velocity * (1 - exp(-drag * age)) / drag
112
+ + 0.5 * gravity * age * age
113
+ ```
114
+
115
+ Frame-rate independence, pause, and slow-motion all come free. Add curl
116
+ noise of the start position for turbulence; rotate the start offset around a
117
+ drifting anchor for vortex swirl.
118
+ - **Kill without touching buffers**: a dead particle outputs a position outside
119
+ the clip volume and the GPU discards the whole quad before rasterization.
120
+ - **Silhouettes are procedural** in the fragment stage, from centered UV:
121
+ a feathered dot (`smoothstep` on radius) for anything that is light; an
122
+ fbm-eroded puff for smoke; a velocity-stretched streak for sparks; an
123
+ angular chip (radius wobbled by `sin(angle * k + seed)`) for debris; a thin
124
+ ring for shockwaves. No sprite textures required.
125
+ - Color is a 3–4 stop gradient over normalized lifetime (birth → early → late
126
+ → death); alpha is a fade-in × fade-out pair of smoothsteps.
127
+ - **Blending is a role decision**: additive for anything that IS light —
128
+ sparks, glitter, glow. Normal blending for smoke and mist, so they genuinely
129
+ occlude and give the effect depth. A scene made only of additive layers has
130
+ no darkness in it.
131
+ - Emission rate: accumulate `rate * dt`, emit the whole part, carry the
132
+ remainder — truncating emits zero at high frame rates and bursts at low
133
+ ones. Cap the per-frame emit so a one-second hitch cannot dump a thousand
134
+ particles into a single frame.
135
+ - Emit a travelling effect's particles from several points along its length,
136
+ not one — a bolt sheds along its whole span, and a single origin makes every
137
+ batch read as a starburst.
138
+
139
+ ### Particles do not render round by default
140
+
141
+ `new THREE.PointsMaterial({ size, color })` draws every point as a **hard-edged
142
+ camera-facing square** — no falloff, no fade. Shipping the default is the
143
+ "square flying things" look, the single most common particle defect in real
144
+ builds. For a quick `Points`-based job, the fix is one canvas sprite:
56
145
 
57
146
  ```ts
58
147
  /** A soft round dot drawn into a canvas — no network, no asset, works offline. */
@@ -70,77 +159,101 @@ function dotTexture(size = 64): THREE.Texture {
70
159
  t.needsUpdate = true;
71
160
  return t;
72
161
  }
73
-
74
- const material = new THREE.PointsMaterial({
75
- map: dotTexture(), // ← without this every mote is a square
76
- color: 0xffb98a,
77
- size: 0.06,
78
- sizeAttenuation: true, // near motes bigger than far ones
79
- transparent: true,
80
- depthWrite: false, // motes must not occlude each other
81
- blending: THREE.AdditiveBlending,
82
- });
83
162
  ```
84
163
 
85
- For a mote with real art (embers with structure, snowflakes, leaves), generate the
86
- sprite instead `npx genex image --transparent "single soft round ember, black
87
- background"` and use it as `map`. The canvas dot is the right default for
88
- anything that is just light.
164
+ …set as `map` with `transparent: true`, `depthWrite: false`, additive blending.
165
+ For a mote with real art (embers with structure, snowflakes, leaves), generate
166
+ the sprite instead `npx genex image --transparent "single soft round ember,
167
+ black background"`. `depthWrite: false` disables the depth **write**, not the
168
+ **test**: a mote fractionally below the floor vanishes, so spawn above the
169
+ surface, not on it.
89
170
 
90
- `depthWrite: false` turns off the depth **write**, not the depth **test**: a
91
- particle still gets rejected by geometry in front of it. That's what you want —
92
- but it also means a mote sitting fractionally below the floor vanishes. Spawn
93
- above the surface, not on it.
171
+ ## Filaments: everything long and bright is one strip
94
172
 
95
- ## Bursts are pooled, never allocated
173
+ Lightning, beams, energy tendrils, leashes, trails — one parameterization
174
+ covers them all, and it is the purest case of the law above.
96
175
 
97
- A burst that runs `new Points(...)` per explosion allocates during the exact
98
- frame the player is watching. Pre-build one pool at load, take from it, return on
99
- death the same shape the rest of the scene uses for bombs and flames.
176
+ **Geometry**: a ladder of quads whose vertices carry only `(t, side)` how far
177
+ along the filament, which edge of the strip with one instance per filament
178
+ (an index attribute). The vertex stage turns that pair into a world position
179
+ every frame by stacking three terms:
100
180
 
101
- ```ts
102
- const N = 240;
103
- const pos = new Float32Array(N * 3);
104
- const vel = new Float32Array(N * 3);
105
- const life = new Float32Array(N); // seconds remaining; 0 = free
106
- const geo = new THREE.BufferGeometry();
107
- geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
108
- const points = new THREE.Points(geo, material);
109
- points.frustumCulled = false; // positions move; the bounds don't follow
110
-
111
- function burst(x: number, y: number, z: number, n = 24, speed = 3): void {
112
- let spawned = 0;
113
- for (let i = 0; i < N && spawned < n; i++) {
114
- if (life[i] > 0) continue;
115
- const th = Math.random() * Math.PI * 2;
116
- const ph = Math.acos(2 * Math.random() - 1);
117
- const s = speed * (0.5 + Math.random() * 0.5);
118
- vel[i * 3] = Math.sin(ph) * Math.cos(th) * s;
119
- vel[i * 3 + 1] = Math.abs(Math.cos(ph)) * s; // bias up — debris arcs
120
- vel[i * 3 + 2] = Math.sin(ph) * Math.sin(th) * s;
121
- pos[i * 3] = x; pos[i * 3 + 1] = y; pos[i * 3 + 2] = z;
122
- life[i] = 0.5 + Math.random() * 0.4;
123
- spawned++;
124
- }
125
- }
181
+ 1. **The axis** — a line from origin to target, optionally bowed by sag. The
182
+ only term that knows where the cast points.
183
+ 2. **The fan** a constant per-filament offset in the plane perpendicular to
184
+ the axis, opening from a near-spread at the hand to a far-spread at the
185
+ target, rolled around the axis by a twist angle. This is what separates one
186
+ filament from the next.
187
+ 3. **The kinks** — octaves of **linearly interpolated** value noise, frequency
188
+ expressed in kinks per metre (scale by the cast's span so a long bolt kinks
189
+ as densely as a short one). Linear on purpose: smooth interpolation rounds
190
+ the corners off, and the corners are the entire reason it reads as
191
+ lightning rather than as a wobbly tube.
126
192
 
127
- function step(dt: number): void {
128
- for (let i = 0; i < N; i++) {
129
- if (life[i] <= 0) continue;
130
- life[i] -= dt;
131
- if (life[i] <= 0) { pos[i * 3 + 1] = -1000; continue; } // park it offscreen
132
- vel[i * 3 + 1] -= 9.8 * dt; // gravity
133
- pos[i * 3] += vel[i * 3] * dt;
134
- pos[i * 3 + 1] += vel[i * 3 + 1] * dt;
135
- pos[i * 3 + 2] += vel[i * 3 + 2] * dt;
136
- }
137
- geo.attributes.position.needsUpdate = true;
138
- }
139
- ```
193
+ Then:
194
+
195
+ - **Face the camera without billboards**: tangent by finite difference along
196
+ `t`; `binormal = normalize(cross(tangent, toCamera))`; offset the vertex by
197
+ `binormal * side * halfWidth`. The strip keeps its apparent thickness from
198
+ any angle without ever being a screen-space line.
199
+ - **Pin the ends**: fade the kink amplitude to zero near `t = 0` and `t = 1`.
200
+ A bolt that lands anywhere other than where it was aimed reads as a bug.
201
+ - **Draw it twice**: a wide soft halo pass under a narrow hot core pass — same
202
+ geometry, same path, different width and falloff. Glow drawn as real
203
+ geometry stays attached to every kink; bloom alone detaches from the shape.
204
+ - **Two clocks**: a restrike clock snaps every filament onto a new shape N
205
+ times a second (`seed = hash(filamentIndex, floor(time * restrike))`) while
206
+ a crawl clock slides the kink phase continuously in between. Together they
207
+ stop a held bolt from looking like a static ribbon. Flicker is **quantized**
208
+ — `brightness = hash(floor(time * flickerSpeed))` — because lightning
209
+ stutters between brightnesses; a sine "breathes", and reads as decoration.
210
+
211
+ **A beam is the same idea one dimension up**: a tube whose vertices carry
212
+ `(t, angle)`, drawn three times at three radii — a halo that is nothing but
213
+ rim, a sheath weighted toward its silhouette edges so it reads as hollow, and
214
+ a core weighted the **opposite** way, brightest where the view ray runs down
215
+ the barrel. Rim-weighted outside, axis-weighted inside, both faces adding:
216
+ that is a volume integral, cheaply, and the inversion is why the middle reads
217
+ as a solid rod of light instead of a lit pipe. Helical coils are the filament
218
+ strip bent around the same radius function; a train of shock discs is an
219
+ instanced ring whose phase is `fract(index / count + time * speed)` — a pure
220
+ function of the clock, no queue on the CPU. And keep the noise families apart:
221
+ a beam's noise is smooth and stretched hard along the flow; a bolt's is
222
+ piecewise-linear. A beam that kinks is a bolt.
223
+
224
+ ## Lights are pooled at boot
225
+
226
+ Adding or removing a dynamic light mid-game changes the lighting program and
227
+ forces the renderer to **recompile every material** — the classic first-cast
228
+ hitch. Create the maximum count at boot (4–6 point lights is plenty), parked
229
+ at zero intensity; acquire on spawn, release on death, and damp intensity
230
+ toward its target so a released light fades instead of cutting. `acquire()`
231
+ returns null when the pool is exhausted — guard every use. Give impacts a
232
+ transient boost added onto the base intensity that decays on its own.
233
+
234
+ Same family of trap: run the renderer's async shader precompile during the
235
+ loading screen so the first cast never stutters on compile.
236
+
237
+ ## Impact grammar
238
+
239
+ One impact is several small systems firing from the **same call site** — the
240
+ place the game already plays the explosion sound. That call site is the proof
241
+ the effect is event-driven rather than ambient decoration.
140
242
 
141
- Call `burst()` from the same place the game already plays the explosion sound —
142
- that call site IS the moment, and it is the proof the effect is event-driven
143
- rather than ambient decoration.
243
+ - **Shake is trauma, not offset.** Hits add trauma (clamped 0..1);
244
+ displacement is `trauma²` (perceptually linear for a linear decay), taken
245
+ from summed sines at incommensurate frequencies — smooth noise reads as
246
+ weight, white noise reads as jitter. Overlapping hits stack naturally;
247
+ small hits feel snappy, big ones heavy.
248
+ - **A shockwave** is the ring silhouette from the particle section, expanded
249
+ over its lifetime, flat on the ground.
250
+ - **Residue that lives** is a pooled ground quad with a noise/distance-field
251
+ fragment shader aged by one normalized uniform: scorch cools, cracks spread
252
+ then dim, frost creeps outward. Trap, measured in this class of effect:
253
+ never sample radial noise on `atan(y, x)` — every radius along a bearing
254
+ gets the same value and the mark becomes dead-straight spokes, a firework
255
+ instead of a burn. Sample the noise in the plane and domain-warp the lookup,
256
+ and the filaments meander and fork.
144
257
 
145
258
  ## Rules
146
259
 
@@ -148,7 +261,12 @@ rather than ambient decoration.
148
261
  - Use normalized lifetime curves instead of scattered time constants.
149
262
  - Derive secondary motion from the same flow or event direction.
150
263
  - Keep bloom as a response to HDR emission, not as the effect's only shape.
151
- - Pool instances and trails; do not allocate per burst.
264
+ - Pool everything at load abilities, particles, decals, lights. Nothing is
265
+ allocated or built during a cast, and a cap retires the oldest cast rather
266
+ than letting the scene grow without bound.
267
+ - Know your draw-call budget: an instanced filament bundle is 2 draw calls at
268
+ any filament count; a particle system is 1. If an effect's cost scales with
269
+ its visual density, restructure it so it doesn't.
152
270
  - Expose spawn, simulation, overdraw, and luminance debug views.
153
271
  - Include a non-bloom baseline that remains legible.
154
272
  - Never ship a `PointsMaterial` without a `map` or `alphaMap` — see above.
@@ -156,8 +274,10 @@ rather than ambient decoration.
156
274
  ## Routing boundary
157
275
 
158
276
  Keep accumulated screen frost, persistent weather/surface state, and ground
159
- material changes in the request's owning scene system. Impact **residue** on a
160
- surface (scorch, bullet holes) is a decal `$genex-ai-image` owns that; the
161
- spark that throws the residue is this skill, and a hit usually wants both.
162
- Keep ship-space plasma, generated wakes, sparks, and pooled debris in this
163
- skill.
277
+ material changes in the request's owning scene system. Impact residue splits
278
+ by whether it must change while standing: residue that ages or rescales live
279
+ (spreading cracks, creeping frost, a cooling scorch) is a procedural decal in
280
+ this skill; static art-directed residue (bullet holes, a painted scorch) is a
281
+ generated decal — `$genex-ai-image` owns that. The spark that throws either
282
+ kind of residue is this skill, and a hit usually wants both. Keep ship-space
283
+ plasma, generated wakes, sparks, and pooled debris in this skill.
@@ -169,7 +169,7 @@ imports — pass it straight to your JS-eval tool.
169
169
  ```js
170
170
  (() => {
171
171
  const S = window.__scene, MIN_M2 = 1.0; // judge real faces, not GLB slivers
172
- const out = { stretched: [], zfight: [] };
172
+ const out = { stretched: [], zfight: [], skipped: [] };
173
173
  const xf = (e,x,y,z) => [e[0]*x+e[4]*y+e[8]*z+e[12], e[1]*x+e[5]*y+e[9]*z+e[13], e[2]*x+e[6]*y+e[10]*z+e[14]];
174
174
  const sub = (a,b) => [a[0]-b[0], a[1]-b[1], a[2]-b[2]];
175
175
  const cross = (a,b) => [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]];
@@ -211,31 +211,64 @@ imports — pass it straight to your JS-eval tool.
211
211
  });
212
212
  out.stretched.sort((a,b) => b.m2 - a.m2); // biggest surfaces first
213
213
 
214
- // opaque depth-writing meshes that interpenetrate AND share a face plane
215
- const solid = [];
214
+ // z-fight: opaque depth-writing faces that LIE IN the same axis-aligned
215
+ // world plane, facing the SAME way, with overlapping area. Butted solids —
216
+ // faces meeting back-to-back, opposite normals — are safe and never flagged
217
+ // (backface culling hides one of the two). Instanced meshes are expanded.
218
+ const FIGHT_M2 = 0.04, PE = 1e-3;
219
+ const mul = (A,B,o) => { const r = new Array(16);
220
+ for (let c = 0; c < 4; c++) for (let w = 0; w < 4; w++)
221
+ r[c*4+w] = A[w]*B[o+c*4] + A[4+w]*B[o+c*4+1] + A[8+w]*B[o+c*4+2] + A[12+w]*B[o+c*4+3];
222
+ return r; };
223
+ const byMesh = [];
216
224
  S.traverse(o => {
217
225
  const m = Array.isArray(o.material) ? o.material[0] : o.material;
218
- if (!o.isMesh || o.isInstancedMesh || !m || m.transparent || m.depthWrite === false) return;
219
- if (!o.geometry.boundingBox) o.geometry.computeBoundingBox();
220
- const bb = o.geometry.boundingBox, e = o.matrixWorld.elements;
221
- const lo = [Infinity,Infinity,Infinity], hi = [-Infinity,-Infinity,-Infinity];
222
- for (const cx of [bb.min.x,bb.max.x]) for (const cy of [bb.min.y,bb.max.y]) for (const cz of [bb.min.z,bb.max.z]) {
223
- const w = xf(e,cx,cy,cz);
224
- for (let i = 0; i < 3; i++) { lo[i] = Math.min(lo[i],w[i]); hi[i] = Math.max(hi[i],w[i]); }
226
+ if (!o.isMesh || !m || m.transparent || m.depthWrite === false) return;
227
+ const g = o.geometry, pos = g.attributes.position, idx = g.index;
228
+ const tris = (idx ? idx.count : pos.count) / 3, insts = o.isInstancedMesh ? o.count : 1;
229
+ const name = o.name || o.type;
230
+ if (tris * insts > 300000) { out.skipped.push(name); return; }
231
+ const mats = [];
232
+ if (o.isInstancedMesh) { const a = o.instanceMatrix.array, w = o.matrixWorld.elements;
233
+ for (let i = 0; i < insts; i++) mats.push(mul(w, a, i*16)); }
234
+ else mats.push(o.matrixWorld.elements);
235
+ const planes = [];
236
+ for (const e of mats) for (let t = 0; t < tris; t++) {
237
+ const ix = [0,1,2].map(k => idx ? idx.getX(t*3+k) : t*3+k);
238
+ const p = ix.map(i => xf(e, pos.getX(i), pos.getY(i), pos.getZ(i)));
239
+ const nc = cross(sub(p[1],p[0]), sub(p[2],p[0])), l = len(nc);
240
+ if (l < 1e-9) continue;
241
+ for (let k = 0; k < 3; k++) {
242
+ if (Math.abs(nc[k]/l) < 0.999) continue; // axis-aligned faces only
243
+ for (const s of (m.side === 2 ? [1,-1] : [Math.sign(nc[k])])) { // 2 = DoubleSide
244
+ const u = (k+1)%3, v = (k+2)%3, c = p[0][k];
245
+ let pl = planes.find(q => q.k === k && q.s === s && Math.abs(q.c - c) < PE);
246
+ if (!pl) planes.push(pl = { k, s, c, area: 0, lo:[Infinity,Infinity], hi:[-Infinity,-Infinity] });
247
+ pl.area += l/2;
248
+ for (const q of p) { pl.lo[0] = Math.min(pl.lo[0], q[u]); pl.hi[0] = Math.max(pl.hi[0], q[u]);
249
+ pl.lo[1] = Math.min(pl.lo[1], q[v]); pl.hi[1] = Math.max(pl.hi[1], q[v]); }
250
+ }
251
+ }
252
+ }
253
+ let kept = planes.filter(pl => pl.area >= FIGHT_M2);
254
+ if (kept.length > 500) kept = kept.sort((x,y) => y.area - x.area).slice(0, 500);
255
+ // overlapping coplanar faces WITHIN one mesh (a floor added twice, merged dups):
256
+ for (const pl of kept) {
257
+ const bb = (pl.hi[0]-pl.lo[0]) * (pl.hi[1]-pl.lo[1]);
258
+ if (pl.area > bb * 1.02 + 1e-6)
259
+ out.zfight.push({ a: name, b: name + " (itself)", plane: "xyz"[pl.k] + "=" + pl.c.toFixed(3) });
225
260
  }
226
- solid.push({ o, lo, hi });
261
+ byMesh.push({ name, planes: kept });
227
262
  });
228
- const E = 1e-4;
229
- for (let i = 0; i < solid.length; i++) for (let j = i+1; j < solid.length; j++) {
230
- const A = solid[i], B = solid[j];
231
- if ([0,1,2].some(k => Math.min(A.hi[k],B.hi[k]) - Math.max(A.lo[k],B.lo[k]) <= E)) continue;
232
- const shared = [];
233
- for (const k of [0,1,2]) {
234
- if (Math.abs(A.lo[k]-B.lo[k]) < E) shared.push("min."+"xyz"[k]+"="+A.lo[k].toFixed(2));
235
- if (Math.abs(A.hi[k]-B.hi[k]) < E) shared.push("max."+"xyz"[k]+"="+A.hi[k].toFixed(2));
263
+ for (let i = 0; i < byMesh.length; i++) for (let j = i+1; j < byMesh.length; j++)
264
+ for (const a of byMesh[i].planes) for (const b of byMesh[j].planes) {
265
+ if (a.k !== b.k || a.s !== b.s || Math.abs(a.c - b.c) >= PE) continue;
266
+ const du = Math.min(a.hi[0],b.hi[0]) - Math.max(a.lo[0],b.lo[0]);
267
+ const dv = Math.min(a.hi[1],b.hi[1]) - Math.max(a.lo[1],b.lo[1]);
268
+ if (du > 0 && dv > 0 && du*dv >= FIGHT_M2)
269
+ out.zfight.push({ a: byMesh[i].name, b: byMesh[j].name,
270
+ plane: "xyz"[a.k] + (a.s > 0 ? "+" : "-") + "=" + a.c.toFixed(3), overlapM2: +(du*dv).toFixed(2) });
236
271
  }
237
- if (shared.length) out.zfight.push({ a:A.o.name||"mesh", b:B.o.name||"mesh", coplanar:shared });
238
- }
239
272
  return out;
240
273
  })()
241
274
  ```
@@ -247,16 +280,28 @@ generated GLB's authored UVs produce hundreds of tiny-triangle readings that
247
280
  drown the real finding — raise it if a model still floods the list, and note in
248
281
  the handoff that you did.
249
282
 
250
- **`zfight` should be empty.** Every entry is two solids that interpenetrate AND
251
- share a face plane, which will fight and flicker the moment the camera moves.
252
-
253
- Both are wiring defects, so fix the wiring: `worldUV` (`$genex-ai-texture`) for
254
- the first; for the second, stop the solids overlapping butt the spans end to
255
- end rather than crossing them at the corners.
283
+ **`zfight` should be empty.** Every entry is two opaque, depth-writing surfaces
284
+ lying IN the same plane, facing the same way, with real shared area exactly
285
+ the geometry that flickers the moment the camera moves. Flat-on-flat counts (a
286
+ road quad at the ground's exact y, a decal flush on a wall), instanced kit
287
+ pieces are expanded per instance, and an `(itself)` entry means one mesh
288
+ carries overlapping coplanar faces usually the same floor added twice.
289
+ Butted solids whose faces meet back-to-back are safe and never flagged, so
290
+ keep butting kit pieces end to end. `skipped` names meshes too heavy to scan
291
+ (>300k effective triangles).
292
+
293
+ Both are wiring defects, so fix the wiring: `worldUV` (`$genex-ai-texture`)
294
+ for the first; for a `zfight` pair, give the surfaces a real gap (1–2 mm
295
+ indoors, ~1 cm at city scale), or let a decal win with `polygonOffset: true,
296
+ polygonOffsetFactor: -1, polygonOffsetUnits: -1` + `depthWrite: false`.
297
+ Near-coplanar shimmer in the DISTANCE with no pair listed here is the camera's
298
+ depth range, not geometry — `cameraNearFar` + `depthRendererOptions()` from
299
+ the quality kit's `depth.ts` own that (`$genex-threejs-adaptive-quality`).
256
300
 
257
301
  Run against the shipped BomberDome build, this printed `aspect: 102, m2: 17,
258
- mPerTile: "0.17x17.00"` for the wall tops and four coplanar pairs at
259
- `max.y=2.40`. Both had been in front of the agent for an hour of screenshots.
302
+ mPerTile: "0.17x17.00"` for the wall tops, and the interpenetrating wall pairs
303
+ sharing their top plane at y=2.40. Both had been in front of the agent for an
304
+ hour of screenshots.
260
305
 
261
306
  **Grain shimmer — inspect the mechanism, not the pixels.** A pixel diff is
262
307
  confounded by the scene's own ambient motion; the deterministic check is to read