@genex-ai/cli-demo 0.52.0-dev.115 → 0.53.0-dev.117

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
@@ -1949,13 +1949,99 @@ async function detectUiPhases(cwd = process.cwd()) {
1949
1949
  return true;
1950
1950
  }
1951
1951
  }
1952
+ function ctorArgs(content, ctor) {
1953
+ const out = [];
1954
+ const re = new RegExp(`new\\s+(?:THREE\\.)?${ctor}\\s*\\(`, "g");
1955
+ let m;
1956
+ while (m = re.exec(content)) {
1957
+ let i = re.lastIndex;
1958
+ let depth = 1;
1959
+ while (i < content.length && depth > 0) {
1960
+ const ch = content[i];
1961
+ if (ch === "(" || ch === "{" || ch === "[") depth++;
1962
+ else if (ch === ")" || ch === "}" || ch === "]") depth--;
1963
+ i++;
1964
+ }
1965
+ out.push({ text: content.slice(re.lastIndex, i - 1), index: m.index });
1966
+ }
1967
+ return out;
1968
+ }
1969
+ function splitArgs(text) {
1970
+ const out = [];
1971
+ let depth = 0;
1972
+ let start = 0;
1973
+ for (let i = 0; i < text.length; i++) {
1974
+ const ch = text[i];
1975
+ if (ch === "(" || ch === "{" || ch === "[") depth++;
1976
+ else if (ch === ")" || ch === "}" || ch === "]") depth--;
1977
+ else if (ch === "," && depth === 0) {
1978
+ out.push(text.slice(start, i).trim());
1979
+ start = i + 1;
1980
+ }
1981
+ }
1982
+ out.push(text.slice(start).trim());
1983
+ return out;
1984
+ }
1985
+ var lineOf = (content, index) => content.slice(0, index).split("\n").length;
1986
+ var DEPTH_RATIO_LIMIT = 1e6;
1987
+ async function detectSurfaceScan(cwd = process.cwd()) {
1988
+ const found = { guessedRepeat: [], squarePoints: [], depthRange: [] };
1989
+ const srcDir = path11.join(cwd, "src");
1990
+ let entries;
1991
+ try {
1992
+ entries = await fs10.readdir(srcDir, { recursive: true });
1993
+ } catch {
1994
+ return found;
1995
+ }
1996
+ for (const rel of entries) {
1997
+ if (rel.includes("node_modules")) continue;
1998
+ if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
1999
+ const raw = await fs10.readFile(path11.join(srcDir, rel), "utf8").catch(() => "");
2000
+ if (!raw) continue;
2001
+ const content = raw.replace(/\/\*[\s\S]*?\*\//g, (m2) => m2.replace(/[^\n]/g, " ")).replace(/(^|[^:])\/\/[^\n]*/gm, (m2, p1) => p1 + " ".repeat(m2.length - p1.length));
2002
+ const repeatRe = /\.repeat\.set\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g;
2003
+ let m;
2004
+ while (m = repeatRe.exec(content)) {
2005
+ const u = Number(m[1]);
2006
+ const v = Number(m[2]);
2007
+ if (u === v) continue;
2008
+ found.guessedRepeat.push({
2009
+ where: `${rel}:${lineOf(content, m.index)}`,
2010
+ detail: `repeat.set(${m[1]}, ${m[2]})`
2011
+ });
2012
+ }
2013
+ for (const call of ctorArgs(content, "PointsMaterial")) {
2014
+ if (/\b(map|alphaMap)\s*:/.test(call.text)) continue;
2015
+ found.squarePoints.push({
2016
+ where: `${rel}:${lineOf(content, call.index)}`,
2017
+ detail: "PointsMaterial with no map/alphaMap"
2018
+ });
2019
+ }
2020
+ for (const call of ctorArgs(content, "PerspectiveCamera")) {
2021
+ const args = splitArgs(call.text);
2022
+ if (args.length < 4) continue;
2023
+ const near = Number(args[2]);
2024
+ const far = Number(args[3]);
2025
+ if (!isFinite(near) || !isFinite(far) || near <= 0 || far <= near) continue;
2026
+ const ratio = far / near;
2027
+ if (ratio <= DEPTH_RATIO_LIMIT) continue;
2028
+ found.depthRange.push({
2029
+ where: `${rel}:${lineOf(content, call.index)}`,
2030
+ detail: `near ${near} / far ${far}`,
2031
+ ratio
2032
+ });
2033
+ }
2034
+ }
2035
+ return found;
2036
+ }
1952
2037
  async function detectFeatures(log, cwd = process.cwd()) {
1953
2038
  return {
1954
2039
  embedSdkVersion: await detectEmbedSdkVersion(cwd),
1955
2040
  multiplayer: await detectMultiplayer(cwd),
1956
2041
  matchmaking: await detectMatchmaking(log, cwd),
1957
2042
  gameStateUsed: await detectGameStateUsage(cwd),
1958
- uiPhases: await detectUiPhases(cwd)
2043
+ uiPhases: await detectUiPhases(cwd),
2044
+ surfaces: await detectSurfaceScan(cwd)
1959
2045
  };
1960
2046
  }
1961
2047
  function advisoryNudges(log, d) {
@@ -1979,6 +2065,26 @@ function advisoryNudges(log, d) {
1979
2065
  "No menu/loader screens detected (no data-phase/setPhase) \u2014 players get a bare HUD with no title screen and no styled loading. Run the genex-threejs-game-ui plan (loader, menu, screen tiers); a cinematic menu is two commands away (genex-ai-menu)."
1980
2066
  );
1981
2067
  }
2068
+ surfaceNudges(log, d.surfaces);
2069
+ }
2070
+ function surfaceNudges(log, s) {
2071
+ if (s.guessedRepeat.length) {
2072
+ const list = s.guessedRepeat.map((d) => `${d.where} \u2014 ${d.detail}`).join("; ");
2073
+ log.warn(
2074
+ `Texture tiling picked by eye, not derived from the mesh: ${list}. A hand-chosen repeat is right for at most ONE face \u2014 a BoxGeometry gives all six faces 0..1 UVs whatever their world size, and repeat lives on the Texture, so every mesh sharing that texture inherits it. Derive the UVs from world size instead: genex-ai-texture ships worldUV(geometry, TILE_M), after which repeat stays (1, 1) and cannot be wrong.`
2075
+ );
2076
+ }
2077
+ if (s.squarePoints.length) {
2078
+ const list = s.squarePoints.map((d) => d.where).join(", ");
2079
+ log.warn(
2080
+ `Particles will render as hard SQUARES: ${list} builds a PointsMaterial with no map/alphaMap, so every point is an opaque camera-facing quad. Give it a soft sprite (genex image --transparent) or a round gl_PointCoord cutout \u2014 and check the particle belongs there at all: genex-threejs-procedural-vfx decides that per moment.`
2081
+ );
2082
+ }
2083
+ for (const d of s.depthRange) {
2084
+ log.warn(
2085
+ `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.`
2086
+ );
2087
+ }
1982
2088
  }
1983
2089
  async function firstPreviewNudge(log, cwd = process.cwd()) {
1984
2090
  const meta = await readProject(cwd);
@@ -2503,8 +2609,9 @@ async function reportTextureSeam(url, log) {
2503
2609
  `Visible ${r.worstAxis} tiling seam \u2014 the tile boundary jumps ${r.worstRatio}\xD7 this texture's own detail (want \u2264 ${r.tolerance}\xD7).`
2504
2610
  );
2505
2611
  log.plain(" It will read as a repeating grid on any large surface. Before wiring it in:");
2506
- log.plain(' \u2022 regenerate with "seamless tiling, no visible edges" in the prompt (--terrain for ground), or');
2507
- log.plain(" \u2022 lower the mesh's UV repeat so the seam falls off-camera.");
2612
+ log.plain(' \u2022 regenerate with "seamless tiling, no visible edges" in the prompt (--terrain for ground).');
2613
+ log.plain(" Do NOT hide it by tiling less: a hand-picked repeat trades a seam you can name for a");
2614
+ log.plain(" stretched texture you can't. Scale belongs in the UVs (worldUV \u2014 genex-ai-texture).");
2508
2615
  log.plain(` Re-check any image any time: ${c.cyan("npx genex ui seams --in <url>")}`);
2509
2616
  } catch {
2510
2617
  }
@@ -4084,7 +4191,7 @@ async function uiSeams(opts, log) {
4084
4191
  return;
4085
4192
  }
4086
4193
  fail(
4087
- `Visible ${r.worstAxis} tiling seam in ${name}: the tile boundary jumps ${r.worstRatio}\xD7 the texture's own detail (want \u2264 ${tol}\xD7). Regenerate it seamless/tileable, or lower the mesh's UV repeat so the seam falls off-camera.`
4194
+ `Visible ${r.worstAxis} tiling seam in ${name}: the tile boundary jumps ${r.worstRatio}\xD7 the texture's own detail (want \u2264 ${tol}\xD7). Regenerate it seamless/tileable. Don't hide it behind a hand-picked UV repeat \u2014 that trades a seam for a stretched texture (scale belongs in the UVs: worldUV, genex-ai-texture).`
4088
4195
  );
4089
4196
  }
4090
4197
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.52.0-dev.115",
3
+ "version": "0.53.0-dev.117",
4
4
  "description": "Set up your ~/.claude workspace, authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,6 +40,10 @@ published game, and remixes alike).
40
40
 
41
41
  ## Apply it (tiling material)
42
42
 
43
+ Load the texture, then **measure the UVs off the geometry** — never set `repeat`
44
+ by eye. `worldUV` (next section) is the whole reason this is two lines and not a
45
+ judgement call.
46
+
43
47
  ```ts
44
48
  import * as THREE from "three";
45
49
 
@@ -48,25 +52,96 @@ const TEXTURE_URL = "https://assets.genex.technology/generations/<id>/texture-ba
48
52
  const map = await new THREE.TextureLoader().loadAsync(TEXTURE_URL);
49
53
  map.colorSpace = THREE.SRGBColorSpace;
50
54
  map.wrapS = map.wrapT = THREE.RepeatWrapping;
51
- map.repeat.set(8, 8); // tile count — raise for large surfaces
52
55
  map.anisotropy = renderer.capabilities.getMaxAnisotropy();
56
+ // NOTE: no map.repeat here, on purpose — worldUV puts the scale in the UVs.
53
57
 
54
- const material = new THREE.MeshStandardMaterial({ map, roughness: 0.9, metalness: 0 });
55
- groundMesh.material = material; // e.g. a PlaneGeometry ground
58
+ const TILE_M = 4; // one tile covers 4×4 metres choose ONCE per material
59
+ const geo = worldUV(new THREE.PlaneGeometry(200, 200), TILE_M);
60
+ const ground = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ map, roughness: 0.9 }));
61
+ ground.rotation.x = -Math.PI / 2;
62
+ scene.add(ground);
63
+ ```
64
+
65
+ Same two lines for a wall, a kerb, a platform, a crate — any shape, any size:
66
+
67
+ ```ts
68
+ const wallGeo = worldUV(new THREE.BoxGeometry(19, 2.4, 1), TILE_M);
56
69
  ```
57
70
 
58
71
  ### Terrain
59
72
 
60
- Generate with `--terrain`, lay a large `PlaneGeometry` (rotated flat), and raise
61
- `repeat` so the texture tiles densely across it:
73
+ Generate with `--terrain` and lay a large `PlaneGeometry` (rotated flat). Scale is
74
+ the same call — `worldUV(geo, TILE_M)`. Pick `TILE_M` by what the material IS: a
75
+ 4 m gravel tile and a 0.6 m tile of floorboards are both right, for different
76
+ materials. What is never right is a different density on two faces of one object.
77
+
78
+ ## Why you cannot pick `repeat` by eye
79
+
80
+ Two facts make a hand-chosen `repeat` wrong on everything but the one face you
81
+ were looking at:
82
+
83
+ 1. **Every `BoxGeometry` face spans UV 0..1 regardless of its world size.** A
84
+ `Box(1, 2.4, 17)` gives its 1×17 m top face the same 0..1 UV square as its
85
+ 17×2.4 m side. One `repeat` therefore cannot serve both.
86
+ 2. **`repeat` lives on the `Texture`, not the `Material`.** Two meshes sharing one
87
+ loaded texture share its `repeat`, `offset` and `wrapS/T`. Setting it for the
88
+ wall silently re-tiles the crates. (If you truly need two densities from one
89
+ image, `map.clone()` — but `worldUV` means you almost never do.)
90
+
91
+ A real game shipped `wallTex.repeat.set(6, 1)` on a four-box wall ring. The number
92
+ was tuned against the long side face and landed there at a near-perfect 1.2:1 —
93
+ and the same number put **1:102** on the wall tops, which read to the player as
94
+ wooden planks rather than the riveted steel the texture actually shows. The
95
+ texture was flawless. The wiring was not.
96
+
97
+ ## `worldUV` — measure the UVs, don't guess them
98
+
99
+ Copy this in. It rewrites a geometry's UVs so one tile covers `tileM × tileM`
100
+ metres on **every** face, whatever the shape. After it, leave `repeat` at (1, 1)
101
+ and it cannot be wrong — there is no number left to pick.
62
102
 
63
103
  ```ts
64
- const ground = new THREE.Mesh(new THREE.PlaneGeometry(200, 200), material);
65
- ground.rotation.x = -Math.PI / 2;
66
- map.repeat.set(64, 64);
67
- scene.add(ground);
104
+ /**
105
+ * Box-project a geometry's UVs in world metres. Call once at build time.
106
+ * Units are the geometry's own, so keep mesh.scale at 1 (or bake it in with
107
+ * geometry.scale(...)) — worldUV cannot see a scale applied to the mesh.
108
+ * Returns a non-indexed geometry: a shared vertex on a box corner belongs to
109
+ * faces pointing different ways and cannot carry one UV for both.
110
+ */
111
+ function worldUV(geometry: THREE.BufferGeometry, tileM: number): THREE.BufferGeometry {
112
+ const g = geometry.index ? geometry.toNonIndexed() : geometry;
113
+ const pos = g.attributes.position;
114
+ const uv = new Float32Array(pos.count * 2);
115
+ const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3();
116
+ const ab = new THREE.Vector3(), ac = new THREE.Vector3(), n = new THREE.Vector3();
117
+ for (let t = 0; t < pos.count / 3; t++) {
118
+ const i0 = t * 3, i1 = i0 + 1, i2 = i0 + 2;
119
+ a.fromBufferAttribute(pos, i0);
120
+ b.fromBufferAttribute(pos, i1);
121
+ c.fromBufferAttribute(pos, i2);
122
+ n.crossVectors(ab.subVectors(b, a), ac.subVectors(c, a));
123
+ const nx = Math.abs(n.x), ny = Math.abs(n.y), nz = Math.abs(n.z);
124
+ // Drop the face's dominant axis; the other two are the tile plane. One
125
+ // branch exactly — picking each axis independently collapses a 45° face to
126
+ // a zero-area UV.
127
+ const axis = nx >= ny && nx >= nz ? 0 : ny >= nz ? 1 : 2;
128
+ for (const i of [i0, i1, i2]) {
129
+ const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
130
+ const u = axis === 0 ? z : x;
131
+ const v = axis === 1 ? z : y;
132
+ uv[i * 2] = u / tileM;
133
+ uv[i * 2 + 1] = v / tileM;
134
+ }
135
+ }
136
+ g.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
137
+ return g;
138
+ }
68
139
  ```
69
140
 
141
+ This is box projection: correct for the primitives games build out of (boxes,
142
+ planes, kerbs, platforms, extrusions). A generated GLB arrives with its own
143
+ authored UVs — leave those alone; `worldUV` is for geometry **you** construct.
144
+
70
145
  ## Tiling is checked FOR you — read the verdict
71
146
 
72
147
  `npx genex texture` measures the wrap seam of every texture it generates and
@@ -77,28 +152,17 @@ prints the verdict right under the URL. A bad one looks like this:
77
152
  ```
78
153
 
79
154
  `--terrain` ASKS the model for seamless; it does **not** guarantee it — a shipped
80
- game seamed at 6.6× with `--terrain` set. So when that line appears, fix it
81
- BEFORE wiring the texture in: **regenerate** with "seamless tiling, no visible
82
- edges" in the prompt, or **lower the `repeat`** so the seam falls off-camera. A
83
- good texture prints `tiles cleanly` instead. To re-check any image (including one
84
- you didn't just generate): `npx genex ui seams --in <png|url>`.
85
-
86
- ## Scale by texel density — never hand-pick `repeat`
87
-
88
- The other half of "the floor looks wrong" is a `repeat` guessed out of the air.
89
- Keep texels SQUARE: derive BOTH axes from the surface's real world size and ONE
90
- chosen tile size.
91
-
92
- ```ts
93
- const TILE_M = 4; // one tile covers 4×4 metres — choose once
94
- map.repeat.set(width / TILE_M, depth / TILE_M); // a 40×400 m lane → (10, 100)
95
- ```
96
-
97
- A non-uniform guess like `repeat.set(2, 40)` on a long lane stretches the texture
98
- 20:1 along it: the pattern smears one way and crowds the other. That reads as
99
- "wrong scale / glued together" even when the texture itself is flawless — and it
100
- is exactly what shipped in a real game. Same rule for walls and platforms: derive
101
- from that surface's own width/height, not from the floor's numbers.
155
+ game seamed at 6.6× with `--terrain` set. When that line appears, **regenerate**
156
+ with "seamless tiling, no visible edges" in the prompt before wiring it in. Do
157
+ not try to hide a seam by tiling less: that trades a visible defect you can name
158
+ for a stretched one you can't, and a texture that only survives at `repeat` 1 on
159
+ one axis is a texture that failed. To re-check any image (including one you
160
+ didn't just generate): `npx genex ui seams --in <png|url>`.
161
+
162
+ Note the two checks see different things and you need both to pass: the seam
163
+ check judges the **image**, `worldUV` fixes the **wiring**. A flawless image
164
+ wired at 1:102 and a seamed image tiled perfectly are both defects, and the
165
+ first one is the one a screenshot of the whole arena will not show you.
102
166
 
103
167
  ## Publish checklist
104
168
 
@@ -323,6 +323,17 @@ near 0.2
323
323
  far 3.0e7
324
324
  ```
325
325
 
326
+ **Do not copy those numbers into an ordinary game.** That range only works
327
+ because a planet renderer pairs it with `WebGLRenderer({ logarithmicDepthBuffer:
328
+ true })`; on a stock 24-bit depth buffer it throws the precision away and any two
329
+ surfaces that touch will fight and flicker. The expensive knob is `near`, not
330
+ `far`: resolvable depth at distance z is roughly `z² / (near × 2^24)`, so every
331
+ halving of `near` halves precision at every distance. Set `near` to the closest
332
+ the camera can genuinely get — for a third-person or top-down game that is
333
+ metres, not centimetres — and `far` to the far edge of the world, and the ratio
334
+ takes care of itself. `genex preview` warns when a camera's range is past what a
335
+ plain depth buffer can serve.
336
+
326
337
  It prewarms pipelines by temporarily aiming at representative bodies, then
327
338
  restores both position and quaternion in `finally`.
328
339
 
@@ -1,23 +1,146 @@
1
1
  ---
2
2
  name: genex-threejs-procedural-vfx
3
- description: Author procedural real-time VFX for Genex Three.js games. 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, 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.
4
4
  ---
5
5
 
6
6
  # Genex Three.js Procedural VFX
7
7
 
8
8
  Build effects from an event envelope, motion field, geometry representation, and shading response. Avoid independent particle emitters that happen to share a color.
9
9
 
10
- ## Effect graph
10
+ ## The moment gate — run this BEFORE writing any effect code
11
11
 
12
- ```text
13
- ship/event state
14
- effect-specific geometry or instance attributes
15
- → flow-facing masks or analytic age
16
- material response
17
- pool/lifetime ownership
18
- HDR and bloom contribution
12
+ The visual-direction gate walks **surfaces** and asks "texture or shader?" for
13
+ each. It cannot see effects, because effects don't live on surfaces — they live
14
+ on **moments**. So walk the moments the same way, and decide each one.
15
+
16
+ List every moment the game's own rules fire. Take them from the game's controls
17
+ and contract, not from imagination — a bomb detonates, a crate breaks, a pickup
18
+ is collected, a player dies, a round starts, a shot lands. Then judge each:
19
+
20
+ > **Does the world change here in a way the player should SEE, and does the
21
+ > existing feedback already say it?**
22
+
23
+ Three answers, all legitimate, one forbidden:
24
+
25
+ - **Effect earns it** — the moment has a cause the player made and a consequence
26
+ they must read, and nothing else is carrying it. Build it.
27
+ - **Already covered** — a flash, a sound, hitstop, a decal, or an animation
28
+ already lands the beat. Adding particles buys nothing but fill rate. **Say so
29
+ in one line and move on.**
30
+ - **Deliberately bare** — the look wants restraint. Valid. **Say so in one line.**
31
+ - **Not deciding** — the only wrong answer. A game where bombs detonate and the
32
+ world does not react is not a style choice, it's an unfinished list.
33
+
34
+ Then check the inverse, because the failure runs both ways: **an effect on a
35
+ moment the player didn't cause and can't read is noise.** Ambient particles are
36
+ the usual offender — they sell mood, not information. Budget **one** ambient
37
+ layer for the whole scene, and only if the scene is visibly dead at rest.
38
+
39
+ **Shape is part of this gate.** For a moment made of energy — fire, a blast, a
40
+ shockwave — the question is never "what texture goes on this box?" It is "what
41
+ is this energy shaped like?" A shipped game reasoned "the flame is deliberately
42
+ untextured — it is pure emissive energy" and left a **cube** standing in for
43
+ fire. That sentence answers the surface gate correctly and the moment gate not at
44
+ all. If a primitive is standing in for an effect, it is a placeholder, whatever
45
+ the comment says.
46
+
47
+ ## Particles do not render round by default
48
+
49
+ `new THREE.PointsMaterial({ size, color })` draws every point as a **hard-edged
50
+ camera-facing square**. There is no soft falloff, no roundness, no fade — those
51
+ are things you add. Shipping the default is the "square flying things" look, and
52
+ it is the single most common particle defect in real builds.
53
+
54
+ The fix costs eight lines and no generation call:
55
+
56
+ ```ts
57
+ /** A soft round dot drawn into a canvas — no network, no asset, works offline. */
58
+ function dotTexture(size = 64): THREE.Texture {
59
+ const c = document.createElement("canvas");
60
+ c.width = c.height = size;
61
+ const ctx = c.getContext("2d")!;
62
+ const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
63
+ g.addColorStop(0, "rgba(255,255,255,1)");
64
+ g.addColorStop(0.4, "rgba(255,255,255,0.5)");
65
+ g.addColorStop(1, "rgba(255,255,255,0)");
66
+ ctx.fillStyle = g;
67
+ ctx.fillRect(0, 0, size, size);
68
+ const t = new THREE.Texture(c);
69
+ t.needsUpdate = true;
70
+ return t;
71
+ }
72
+
73
+ const material = new THREE.PointsMaterial({
74
+ map: dotTexture(), // ← without this every mote is a square
75
+ color: 0xffb98a,
76
+ size: 0.06,
77
+ sizeAttenuation: true, // near motes bigger than far ones
78
+ transparent: true,
79
+ depthWrite: false, // motes must not occlude each other
80
+ blending: THREE.AdditiveBlending,
81
+ });
82
+ ```
83
+
84
+ For a mote with real art (embers with structure, snowflakes, leaves), generate the
85
+ sprite instead — `npx genex image --transparent "single soft round ember, black
86
+ background"` — and use it as `map`. The canvas dot is the right default for
87
+ anything that is just light.
88
+
89
+ `depthWrite: false` turns off the depth **write**, not the depth **test**: a
90
+ particle still gets rejected by geometry in front of it. That's what you want —
91
+ but it also means a mote sitting fractionally below the floor vanishes. Spawn
92
+ above the surface, not on it.
93
+
94
+ ## Bursts are pooled, never allocated
95
+
96
+ A burst that runs `new Points(...)` per explosion allocates during the exact
97
+ frame the player is watching. Pre-build one pool at load, take from it, return on
98
+ death — the same shape the rest of the scene uses for bombs and flames.
99
+
100
+ ```ts
101
+ const N = 240;
102
+ const pos = new Float32Array(N * 3);
103
+ const vel = new Float32Array(N * 3);
104
+ const life = new Float32Array(N); // seconds remaining; 0 = free
105
+ const geo = new THREE.BufferGeometry();
106
+ geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
107
+ const points = new THREE.Points(geo, material);
108
+ points.frustumCulled = false; // positions move; the bounds don't follow
109
+
110
+ function burst(x: number, y: number, z: number, n = 24, speed = 3): void {
111
+ let spawned = 0;
112
+ for (let i = 0; i < N && spawned < n; i++) {
113
+ if (life[i] > 0) continue;
114
+ const th = Math.random() * Math.PI * 2;
115
+ const ph = Math.acos(2 * Math.random() - 1);
116
+ const s = speed * (0.5 + Math.random() * 0.5);
117
+ vel[i * 3] = Math.sin(ph) * Math.cos(th) * s;
118
+ vel[i * 3 + 1] = Math.abs(Math.cos(ph)) * s; // bias up — debris arcs
119
+ vel[i * 3 + 2] = Math.sin(ph) * Math.sin(th) * s;
120
+ pos[i * 3] = x; pos[i * 3 + 1] = y; pos[i * 3 + 2] = z;
121
+ life[i] = 0.5 + Math.random() * 0.4;
122
+ spawned++;
123
+ }
124
+ }
125
+
126
+ function step(dt: number): void {
127
+ for (let i = 0; i < N; i++) {
128
+ if (life[i] <= 0) continue;
129
+ life[i] -= dt;
130
+ if (life[i] <= 0) { pos[i * 3 + 1] = -1000; continue; } // park it offscreen
131
+ vel[i * 3 + 1] -= 9.8 * dt; // gravity
132
+ pos[i * 3] += vel[i * 3] * dt;
133
+ pos[i * 3 + 1] += vel[i * 3 + 1] * dt;
134
+ pos[i * 3 + 2] += vel[i * 3 + 2] * dt;
135
+ }
136
+ geo.attributes.position.needsUpdate = true;
137
+ }
19
138
  ```
20
139
 
140
+ Call `burst()` from the same place the game already plays the explosion sound —
141
+ that call site IS the moment, and it is the proof the effect is event-driven
142
+ rather than ambient decoration.
143
+
21
144
  Read [references/vfx-systems.md](references/vfx-systems.md)
22
145
  for ship-conforming reentry shells, capsule wakes, dense instanced
23
146
  spark/debris pools, HDR hierarchy, and implementation limits.
@@ -31,11 +154,14 @@ spark/debris pools, HDR hierarchy, and implementation limits.
31
154
  - Pool instances and trails; do not allocate per burst.
32
155
  - Expose spawn, simulation, overdraw, and luminance debug views.
33
156
  - Include a non-bloom baseline that remains legible.
157
+ - Never ship a `PointsMaterial` without a `map` or `alphaMap` — see above.
34
158
 
35
159
  ## Routing boundary
36
160
 
37
161
  Use `$genex-threejs-temporal-surfaces` only for the screen-space
38
162
  frost/touch-history pipeline. Use `$genex-threejs-precipitation-surfaces` for
39
163
  falling rain or snow, splash flipbooks, and weather events that alter ground
40
- materials. Keep ship-space plasma, generated wakes, sparks,
164
+ materials. Impact **residue** on a surface (scorch, bullet holes) is a decal —
165
+ `$genex-ai-image` owns that; the spark that throws the residue is this skill, and
166
+ a hit usually wants both. Keep ship-space plasma, generated wakes, sparks,
41
167
  and pooled debris in this skill.
@@ -64,7 +64,10 @@ Three.js release or branch, and do not blindly copy demo architecture.
64
64
  the scene alive at rest — an emissive pulse along edges, heat shimmer,
65
65
  drifting dust, a slowly flowing texture. Shader/procedural, zero
66
66
  generations, built with the scene — a world that is perfectly still
67
- reads as a screenshot, not a place;
67
+ reads as a screenshot, not a place. ONE is the budget, not the floor.
68
+ If the answer is particles, `$genex-threejs-procedural-vfx` carries the
69
+ recipe — a bare `PointsMaterial` renders hard squares, and picking this
70
+ bullet without that skill is how "drifting dust" ships as flying boxes;
68
71
  - **every primitive surface — texture it or shade it, decided one by one**:
69
72
  the ground is never the only surface. Walk the walls, barriers, kerbs,
70
73
  platforms and props the game BUILDS out of primitives, and give each a real
@@ -79,7 +82,24 @@ Three.js release or branch, and do not blindly copy demo architecture.
79
82
  ships when this bullet is skipped. `$genex-threejs-procedural-materials`
80
83
  and `$genex-threejs-procedural-vfx` own the craft. Whatever you apply, SEE
81
84
  it in the running game before calling it done — an unverified shader
82
- disfigures as easily as it delights;
85
+ disfigures as easily as it delights. Scale is not a judgement call: derive
86
+ the UVs from world size (`worldUV`, `$genex-ai-texture`) and never hand-pick
87
+ a `repeat` — one `repeat` cannot be right for six box faces of different
88
+ sizes, and a shipped game put 1:102 on a wall top that way;
89
+ - **every moment the rules fire — decide the effect one by one**: the
90
+ surfaces above are only half the scene. Walk the moments the GAME
91
+ CONTRACT already names — a hit lands, a crate breaks, a pickup is taken, a
92
+ player dies, a round starts — and for each ask whether the world changes
93
+ in a way the player should see, and whether existing feedback already says
94
+ it. "A flash and a sound already carry this" is a real answer; so is "bare
95
+ on purpose". Say either in one line. Not deciding is the only wrong answer,
96
+ and it is why games ship where bombs detonate and nothing reacts. Then
97
+ check the inverse — an effect on a moment the player didn't cause and can't
98
+ read is noise, and ambient particles are the usual offender. For a moment
99
+ made of ENERGY (fire, a blast, a shockwave) the question is not "what
100
+ texture goes on this box" but "what shape is this energy": a primitive
101
+ standing in for an effect is a placeholder no matter how deliberate the
102
+ comment says it is. `$genex-threejs-procedural-vfx` owns this gate;
83
103
  - **lighting/atmosphere mood** from the SAME shared style brief the UI gate
84
104
  wrote — one art direction across scene and UI.
85
105
  Planning is not building: effects still land LAST (steps 10–11); this step
@@ -66,6 +66,117 @@ everything twice.
66
66
  "click to aim/resume" cue appears. Headless caveat: `requestPointerLock`
67
67
  throws in headless Chromium — assert the wiring and the unlocked cue in a
68
68
  screenshot, and say plainly that the lock itself needs one manual click.
69
+ 7. **Ask the scene the two things the screenshot cannot answer** (below). Run it
70
+ once, in the same browser you already have open.
71
+
72
+ ### The screenshot has two blind spots — query them
73
+
74
+ A capture of the whole arena is taken from one camera at one distance. Two real
75
+ defect classes are invisible in it and both shipped to players:
76
+
77
+ - **Texture scale on faces you aren't looking at.** A shipped game's wall tops
78
+ were 1:102 — the steel texture read as wooden planks — while the wall *sides*,
79
+ the faces at eye level, were a near-perfect 1.2:1. Nothing in the screenshot
80
+ says which is which; the numbers do.
81
+ - **Coplanar surfaces.** Two boxes overlapping with faces at the same height
82
+ z-fight. It may look stable in a still and flicker the moment the camera
83
+ moves, so a screenshot is the one tool guaranteed to miss it.
84
+
85
+ Expose the scene in dev (`if (import.meta.env.DEV) (window as any).__scene = scene;`),
86
+ then paste this into the browser console once and read the verdict. It needs no
87
+ imports — pass it straight to your JS-eval tool.
88
+
89
+ ```js
90
+ (() => {
91
+ const S = window.__scene, MIN_M2 = 1.0; // judge real faces, not GLB slivers
92
+ const out = { stretched: [], zfight: [] };
93
+ 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]];
94
+ const sub = (a,b) => [a[0]-b[0], a[1]-b[1], a[2]-b[2]];
95
+ 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]];
96
+ const len = a => Math.hypot(a[0],a[1],a[2]);
97
+ const comb = (a,s,b,t) => [a[0]*s+b[0]*t, a[1]*s+b[1]*t, a[2]*s+b[2]*t];
98
+
99
+ // metres-per-tile along U and V, from each triangle's UV Jacobian. Geometry
100
+ // agnostic — a box, a plane and a GLB all measure the same way — and it reads
101
+ // texture.repeat, so it sees what the GPU will actually draw.
102
+ S.traverse(o => {
103
+ if (!o.isMesh) return;
104
+ const m = Array.isArray(o.material) ? o.material[0] : o.material;
105
+ const g = o.geometry, uv = g.attributes && g.attributes.uv;
106
+ if (!m || !m.map || !uv) return;
107
+ const pos = g.attributes.position, idx = g.index, e = o.matrixWorld.elements;
108
+ const rx = m.map.repeat.x, ry = m.map.repeat.y, faces = new Map();
109
+ for (let t = 0; t < (idx ? idx.count : pos.count) / 3; t++) {
110
+ const ix = [0,1,2].map(k => idx ? idx.getX(t*3+k) : t*3+k);
111
+ const p = ix.map(i => xf(e, pos.getX(i), pos.getY(i), pos.getZ(i)));
112
+ const e1 = sub(p[1],p[0]), e2 = sub(p[2],p[0]);
113
+ const nc = cross(e1,e2), area = len(nc)/2;
114
+ if (area < 1e-6) continue;
115
+ const nn = nc.map(v => v/(area*2));
116
+ const U = ix.map(i => uv.getX(i)*rx), V = ix.map(i => uv.getY(i)*ry);
117
+ const du1 = U[1]-U[0], dv1 = V[1]-V[0], du2 = U[2]-U[0], dv2 = V[2]-V[0];
118
+ const det = du1*dv2 - du2*dv1; if (Math.abs(det) < 1e-12) continue;
119
+ const a = len(comb(e1, dv2/det, e2, -dv1/det));
120
+ const b = len(comb(e2, du1/det, e1, -du2/det));
121
+ const k = nn.map(v => v.toFixed(1)).join(",");
122
+ const f = faces.get(k) || { area:0, u:0, v:0 };
123
+ f.area += area; f.u += a*area; f.v += b*area; faces.set(k, f);
124
+ }
125
+ for (const [k,f] of faces) {
126
+ if (f.area < MIN_M2) continue;
127
+ const mu = f.u/f.area, mv = f.v/f.area, asp = Math.max(mu/mv, mv/mu);
128
+ if (asp > 1.5) out.stretched.push({ mesh:o.name||o.type, face:k, aspect:+asp.toFixed(1),
129
+ m2:+f.area.toFixed(1), mPerTile:`${mu.toFixed(2)}x${mv.toFixed(2)}` });
130
+ }
131
+ });
132
+ out.stretched.sort((a,b) => b.m2 - a.m2); // biggest surfaces first
133
+
134
+ // opaque depth-writing meshes that interpenetrate AND share a face plane
135
+ const solid = [];
136
+ S.traverse(o => {
137
+ const m = Array.isArray(o.material) ? o.material[0] : o.material;
138
+ if (!o.isMesh || o.isInstancedMesh || !m || m.transparent || m.depthWrite === false) return;
139
+ if (!o.geometry.boundingBox) o.geometry.computeBoundingBox();
140
+ const bb = o.geometry.boundingBox, e = o.matrixWorld.elements;
141
+ const lo = [Infinity,Infinity,Infinity], hi = [-Infinity,-Infinity,-Infinity];
142
+ 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]) {
143
+ const w = xf(e,cx,cy,cz);
144
+ for (let i = 0; i < 3; i++) { lo[i] = Math.min(lo[i],w[i]); hi[i] = Math.max(hi[i],w[i]); }
145
+ }
146
+ solid.push({ o, lo, hi });
147
+ });
148
+ const E = 1e-4;
149
+ for (let i = 0; i < solid.length; i++) for (let j = i+1; j < solid.length; j++) {
150
+ const A = solid[i], B = solid[j];
151
+ if ([0,1,2].some(k => Math.min(A.hi[k],B.hi[k]) - Math.max(A.lo[k],B.lo[k]) <= E)) continue;
152
+ const shared = [];
153
+ for (const k of [0,1,2]) {
154
+ if (Math.abs(A.lo[k]-B.lo[k]) < E) shared.push("min."+"xyz"[k]+"="+A.lo[k].toFixed(2));
155
+ if (Math.abs(A.hi[k]-B.hi[k]) < E) shared.push("max."+"xyz"[k]+"="+A.hi[k].toFixed(2));
156
+ }
157
+ if (shared.length) out.zfight.push({ a:A.o.name||"mesh", b:B.o.name||"mesh", coplanar:shared });
158
+ }
159
+ return out;
160
+ })()
161
+ ```
162
+
163
+ Read it as: **`stretched` should be empty.** Every entry is a face over a square
164
+ metre whose texels aren't square; `mPerTile` gives you the actual numbers, and
165
+ `m2` tells you whether it's a kerb or a wall. `MIN_M2` is there because a
166
+ generated GLB's authored UVs produce hundreds of tiny-triangle readings that
167
+ drown the real finding — raise it if a model still floods the list, and note in
168
+ the handoff that you did.
169
+
170
+ **`zfight` should be empty.** Every entry is two solids that interpenetrate AND
171
+ share a face plane, which will fight and flicker the moment the camera moves.
172
+
173
+ Both are wiring defects, so fix the wiring: `worldUV` (`$genex-ai-texture`) for
174
+ the first; for the second, stop the solids overlapping — butt the spans end to
175
+ end rather than crossing them at the corners.
176
+
177
+ Run against the shipped BomberDome build, this printed `aspect: 102, m2: 17,
178
+ mPerTile: "0.17x17.00"` for the wall tops and four coplanar pairs at
179
+ `max.y=2.40`. Both had been in front of the agent for an hour of screenshots.
69
180
 
70
181
  Everything deeper (baselines, seed sweeps, mosaics, budgets) belongs to
71
182
  visual-system work — the sequence above.