@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 +82 -11
- package/package.json +1 -1
- package/templates/controllers/quality/context-loss.ts +76 -0
- package/templates/controllers/quality/depth.ts +96 -0
- package/templates/controllers/quality/gltf-loader.ts +5 -1
- package/templates/skills/genex-game-director/SKILL.md +6 -12
- package/templates/skills/genex-threejs-adaptive-quality/SKILL.md +52 -9
- package/templates/skills/genex-threejs-camera-direction/references/camera-rigs.md +6 -2
- package/templates/skills/genex-threejs-procedural-assets/SKILL.md +13 -0
- package/templates/skills/genex-threejs-procedural-vfx/SKILL.md +197 -77
- package/templates/skills/genex-threejs-visual-validation/SKILL.md +74 -29
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.
|
|
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
|
-
|
|
1025
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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.
|
|
397
|
-
|
|
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
|
|
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
|
|
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
|
-
##
|
|
52
|
+
## The law: dice on the CPU, dimensions in the shader
|
|
49
53
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
215
|
-
|
|
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 ||
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
261
|
+
byMesh.push({ name, planes: kept });
|
|
227
262
|
});
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
259
|
-
|
|
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
|