@hypersoniclabs/helix-mcp 0.2.4 → 0.2.12
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/README.md +81 -11
- package/dist/continuumCanary.d.ts +17 -0
- package/dist/continuumCanary.js +17 -0
- package/dist/continuumCanary.js.map +1 -0
- package/dist/server.d.ts +14 -1
- package/dist/server.js +4058 -162
- package/dist/server.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/dist/vehicleTools.d.ts +86 -0
- package/dist/vehicleTools.js +229 -0
- package/dist/vehicleTools.js.map +1 -0
- package/docs/avatar-face.md +115 -0
- package/docs/bridge.md +98 -0
- package/docs/bring-your-world.md +117 -0
- package/docs/catalog.md +69 -1
- package/docs/character-animation.md +442 -0
- package/docs/character-attachments.md +166 -0
- package/docs/character-world.md +785 -130
- package/docs/continuum.md +153 -0
- package/docs/items.md +73 -0
- package/docs/lighting-world.md +667 -0
- package/docs/locomotion-clip-spec.md +294 -0
- package/docs/manifest.md +31 -6
- package/docs/multiplayer-logic.md +460 -17
- package/docs/multiplayer-templates/chrono-orchard.md +36 -22
- package/docs/multiplayer-templates/collect-a-thon.md +28 -38
- package/docs/multiplayer-templates/collections.md +24 -24
- package/docs/multiplayer-templates/hangout.md +124 -111
- package/docs/multiplayer-templates/npc-wave.md +310 -0
- package/docs/multiplayer-templates/obby.md +13 -16
- package/docs/multiplayer-templates/persistent-progress.md +218 -0
- package/docs/multiplayer-templates/physics-bumper.md +27 -9
- package/docs/multiplayer-templates/physics-football.md +22 -8
- package/docs/multiplayer-templates/relic-bearers.md +12 -15
- package/docs/multiplayer-templates/server-motion.md +16 -19
- package/docs/multiplayer-templates/shooter-range.md +275 -0
- package/docs/multiplayer-templates/team-control.md +28 -15
- package/docs/multiplayer-templates/turn-arena.md +31 -22
- package/docs/multiplayer-templates/voice-radio.md +166 -0
- package/docs/multiplayer-templates/wave-survival.md +7 -8
- package/docs/multiplayer-templates/world-shop.md +240 -0
- package/docs/multiplayer-world.md +222 -129
- package/docs/npc-world.md +623 -0
- package/docs/publishing.md +108 -28
- package/docs/purchases.md +223 -0
- package/docs/scene-performance.md +64 -0
- package/docs/screenshots.md +140 -0
- package/docs/sdk.md +324 -5
- package/docs/shooter-worlds.md +537 -0
- package/docs/terrain.md +173 -0
- package/docs/upgrades.md +324 -0
- package/docs/vehicles.md +727 -0
- package/docs/world-inspect.md +156 -0
- package/docs/world-look.md +241 -0
- package/docs/world-recipe.md +65 -6
- package/package.json +15 -4
- package/skills/README.md +91 -0
- package/skills/helix-assets/SKILL.md +491 -0
- package/skills/helix-assets/references/asset-sources.md +143 -0
- package/skills/helix-assets/references/vault-api.md +105 -0
- package/skills/helix-avatar-qa/SKILL.md +85 -0
- package/skills/helix-avatars/SKILL.md +206 -0
- package/skills/helix-avatars/references/contract.md +166 -0
- package/skills/helix-avatars/references/dynamics.md +367 -0
- package/skills/helix-avatars/references/face.md +50 -0
- package/skills/helix-avatars/references/publish.md +76 -0
- package/skills/helix-avatars/references/qa.md +251 -0
- package/skills/helix-avatars/references/rigging.md +88 -0
- package/skills/helix-avatars/references/source-generated.md +190 -0
- package/skills/helix-avatars/references/source-model.md +90 -0
- package/skills/helix-avatars/references/source-rigid.md +90 -0
- package/skills/helix-avatars/references/source-vrm.md +61 -0
- package/skills/helix-gauntlet/SKILL.md +128 -0
- package/skills/helix-multiplayer/SKILL.md +150 -0
- package/skills/helix-multiplayer/references/dsl-capability-map.md +107 -0
- package/skills/helix-vehicles/SKILL.md +218 -0
- package/skills/helix-vehicles/references/addons.md +212 -0
- package/skills/helix-vehicles/references/appearance.md +339 -0
- package/skills/helix-vehicles/references/audio-import.md +138 -0
- package/skills/helix-vehicles/references/audio.md +580 -0
- package/skills/helix-vehicles/references/cabin.md +225 -0
- package/skills/helix-vehicles/references/host-manifest.md +174 -0
- package/skills/helix-vehicles/references/physics-from-specs.md +463 -0
- package/skills/helix-vehicles/references/publish.md +214 -0
- package/skills/helix-vehicles/references/qa.md +177 -0
- package/skills/helix-vehicles/references/reference-package.json +3481 -0
- package/skills/helix-vehicles/references/reference-package.md +69 -0
- package/skills/helix-vehicles/references/source-beamng.md +167 -0
- package/skills/helix-vehicles/references/source-concept.md +38 -0
- package/skills/helix-vehicles/references/source-model.md +100 -0
- package/skills/helix-vehicles/references/source-scratch.md +60 -0
- package/skills/helix-world-build/SKILL.md +376 -0
- package/skills/helix-world-build/references/config-gates.md +104 -0
- package/skills/helix-world-director/SKILL.md +210 -0
- package/skills/helix-world-qa/SKILL.md +371 -0
- package/skills/helix-world-qa/references/perf-budgets.md +240 -0
- package/skills/helix-world-qa/references/perf-handle.md +125 -0
- package/skills/helix-world-qa/references/visual-scorecard.md +85 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# Performance budgets — what was measured, and what it does not say
|
|
2
|
+
|
|
3
|
+
Every number here came off one machine — **Apple M1 Pro, ANGLE Metal, headless Chromium,
|
|
4
|
+
`gl.finish()` per frame, vsync off** — running two real published worlds. Where a limit is a
|
|
5
|
+
guess, it says so. Where a hypothesis was refuted, the refutation is kept, because the wrong
|
|
6
|
+
answer is the one an agent will reach for first.
|
|
7
|
+
|
|
8
|
+
Raw evidence: `qa/perf.txt` in the Night Market and Skyward projects (ablation ladders,
|
|
9
|
+
before/after tables, the light-count sweep).
|
|
10
|
+
|
|
11
|
+
## The failure this exists to catch
|
|
12
|
+
|
|
13
|
+
Night Market shipped at **25.5 ms p50 / 92.7 ms p95, 26% of frames over 33 ms** — 39 fps
|
|
14
|
+
median, 11 fps when the player turned around. It passed every gate we had. QA measured
|
|
15
|
+
triangles, bundle size and draw calls, printed frame rate as an informational line, and never
|
|
16
|
+
failed on it. A world could run at 10 fps and be green.
|
|
17
|
+
|
|
18
|
+
Frame time is the outcome. Triangles, draw calls and light counts are causes. Gate the outcome,
|
|
19
|
+
report the causes.
|
|
20
|
+
|
|
21
|
+
## The refuted hypothesis — do not reinstate it
|
|
22
|
+
|
|
23
|
+
The obvious suspect was shadow-casting point lights: three renders the scene six times per
|
|
24
|
+
frame for each one, so 2 of them were 13 of the 14 render passes, 392 draw calls and 728k
|
|
25
|
+
triangles per frame.
|
|
26
|
+
|
|
27
|
+
**Freezing the shadow maps removed all of that and made the world zero milliseconds faster.**
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
baseline 86.7 ms
|
|
31
|
+
freeze shadow maps (render once) 89.4 ms -2.7 ms saved 0%
|
|
32
|
+
disable shadows entirely 66.9 ms 19.8 ms saved 23%
|
|
33
|
+
stop point lights casting 81.4 ms 5.3 ms saved 6%
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Rendering the shadow maps was free. **Looking them up was not.** The entire 19.8 ms attributed
|
|
37
|
+
to "shadows" is per-pixel PCF sampling in the fragment shader. Shadow-map rendering cost 6% and
|
|
38
|
+
reads as 86% of the draw calls — which is why draw calls are a bad proxy for time.
|
|
39
|
+
|
|
40
|
+
## The actual cause: analytic light COUNT
|
|
41
|
+
|
|
42
|
+
three.js has **no light culling**. Every light in the scene is evaluated in the fragment shader
|
|
43
|
+
for every lit pixel, whether or not it reaches that pixel. Twenty-one local lights cost
|
|
44
|
+
twenty-one lights per pixel across the whole screen.
|
|
45
|
+
|
|
46
|
+
Removing all point lights alone saved **86%** of the frame.
|
|
47
|
+
|
|
48
|
+
It is not a linear cost. It is an occupancy cliff — three unrolls every light into one fragment
|
|
49
|
+
shader, and past ~14 lights the register pressure collapses GPU occupancy:
|
|
50
|
+
|
|
51
|
+
| punctual lights | 0 | 2 | 4 | 6 | 8 | 10 | 12 | 14 | 16 | 20 | 24 |
|
|
52
|
+
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
53
|
+
| p50 ms | 9.2 | 9.9 | 10.7 | 11.6 | 12.8 | 14.1 | 15.6 | 18.4 | 22.7 | **47.0** | **85.2** |
|
|
54
|
+
| fps | 109 | 101 | 94 | 86 | 78 | 71 | 64 | 54 | 44 | **21** | **12** |
|
|
55
|
+
| marginal ms/light | — | 0.35 | 0.40 | 0.45 | 0.60 | 0.65 | 0.75 | 1.40 | 2.15 | **6.08** | **9.55** |
|
|
56
|
+
|
|
57
|
+
*(Night Market's material set, pixel ratio pinned to 2 = 5.18 M pixels, identical pinned pose
|
|
58
|
+
and deterministic 360° yaw sweep per run, one page load per count.)*
|
|
59
|
+
|
|
60
|
+
**The knee is between 12 and 16. The 21st light costs 13× the 4th.**
|
|
61
|
+
|
|
62
|
+
Spot lights, same method: a spot costs about **1.3×** a point light before the cliff and reaches
|
|
63
|
+
the cliff sooner (total 16 → 35.4 ms all-mixed vs 22.7 ms all-point). Which is why the gate
|
|
64
|
+
counts **point + spot combined** rather than per type.
|
|
65
|
+
|
|
66
|
+
## The limits
|
|
67
|
+
|
|
68
|
+
| Limit | Value | Category |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| Frame p50 | **≤ 16.7 ms (60 fps sustained)** | blocking outcome |
|
|
71
|
+
| Frame p95 | **≤ 33.3 ms (30 fps)** | blocking outcome |
|
|
72
|
+
| Punctual lights (point + spot) | warn at **6**, fail at **12**, design target **8** | the cause |
|
|
73
|
+
| Shadow-casting lights | **1 directional, 0 point, ≤1 spot** | the cause |
|
|
74
|
+
| Shadow map size | **≤ 2048², 1024² recommended** | **memory, not time** |
|
|
75
|
+
| Drawing-buffer pixels | **≤ 2.6 M**, as a derived ratio | the largest multiplier |
|
|
76
|
+
| Draw calls | **≤ 300/frame** | CPU submission guard |
|
|
77
|
+
| Triangles | **≤ 500k/frame** | CPU submission guard |
|
|
78
|
+
| Decoded texture bytes | **≤ 256 MB** | **UNVALIDATED — warns, does not block** |
|
|
79
|
+
|
|
80
|
+
### Punctual lights: 8 by design, 12 as a wall
|
|
81
|
+
|
|
82
|
+
The design target is 8 because it leaves **2× headroom** for GPUs weaker than an M1 Pro. The
|
|
83
|
+
wall is 12 because that is where the curve stops being linear — not because 13 lights is
|
|
84
|
+
meaningfully worse than 12, but because past it you cannot predict the cost at all.
|
|
85
|
+
|
|
86
|
+
### Shadow casters: gate the count, not the map size
|
|
87
|
+
|
|
88
|
+
A shadow-casting **point** light renders the whole scene six times per frame. On Skyward one
|
|
89
|
+
brazier flame was 85 of 141 draw calls and 233k of 424k triangles — for a shadow no player could
|
|
90
|
+
read. Zero are allowed. One directional key light carries the shape read for the whole world.
|
|
91
|
+
|
|
92
|
+
### Shadow map size is a MEMORY limit
|
|
93
|
+
|
|
94
|
+
512 vs 1024 measured as **noise** (−1.3 ms, inside run-to-run variance, on the wrong side of
|
|
95
|
+
zero). Shrinking a shadow map returns megabytes, not milliseconds. Say this out loud whenever
|
|
96
|
+
you touch it, or the next agent will "optimise" it expecting frames back and conclude the
|
|
97
|
+
profiler is broken when nothing changes.
|
|
98
|
+
|
|
99
|
+
### Resolution: a budget, never a fixed DPR cap
|
|
100
|
+
|
|
101
|
+
Halving the drawing buffer halved the frame — **69%**, the single largest lever measured. But it
|
|
102
|
+
is a multiplier over every per-pixel cost, not an independent cause: it makes a badly-lit world
|
|
103
|
+
cheaper without making it well-lit.
|
|
104
|
+
|
|
105
|
+
Express it as a budget and derive the ratio:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// The largest pixel ratio (<=2, >=1) that keeps the drawing buffer under budget.
|
|
109
|
+
const BUDGET_PX = 2.6e6;
|
|
110
|
+
function pixelRatioFor(w: number, h: number, dpr = window.devicePixelRatio) {
|
|
111
|
+
return Math.max(1, Math.min(2, dpr, Math.sqrt(BUDGET_PX / (w * h))));
|
|
112
|
+
}
|
|
113
|
+
renderer.setPixelRatio(pixelRatioFor(innerWidth, innerHeight));
|
|
114
|
+
// …and recompute it in the resize handler, not just at boot.
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**A fixed `Math.min(devicePixelRatio, 2)` passes on the window you tested and falls off a cliff
|
|
118
|
+
on a bigger one.** At 1440×900 the budget resolves to ratio 1.42; at 1920×1200 it resolves to
|
|
119
|
+
1.06 — same 2.6 M pixels, same frame time, a bigger window. A fixed cap of 2 would have been
|
|
120
|
+
9.2 M pixels and 3.5× the per-pixel cost. `helix world perf-gate` probes for exactly this by growing the
|
|
121
|
+
window mid-run.
|
|
122
|
+
|
|
123
|
+
### Draw calls and triangles: guards, not the thing that matters
|
|
124
|
+
|
|
125
|
+
Say this plainly whenever you report them. **418 draw calls and 778k triangles per frame
|
|
126
|
+
measured as costing nothing on this machine** — the frozen-shadow run removed 392 calls and 728k
|
|
127
|
+
triangles and returned no time at all. The 300 / 500k limits are deliberately generous guards on
|
|
128
|
+
CPU submission cost for machines weaker than an M1 Pro. A world failing only these is a world
|
|
129
|
+
with a CPU-side risk, not a world that is slow here.
|
|
130
|
+
|
|
131
|
+
### Decoded texture bytes: UNVALIDATED, and therefore not blocking
|
|
132
|
+
|
|
133
|
+
256 MB is a precaution, not a measurement. Decoded texture memory was never observed to cost
|
|
134
|
+
frame time on unified-memory Apple silicon; Skyward carried 252 MB at 500 fps. It is held as a
|
|
135
|
+
budget because a 16 GB shared-memory machine running a browser, an editor and a world has other
|
|
136
|
+
things to pay for. **Do not present it as measured, and never diagnose a slow world with it.**
|
|
137
|
+
|
|
138
|
+
It **warns and does not block**, and the reason is a real result rather than a preference. On
|
|
139
|
+
its first run this limit was blocking, and it failed Skyward at **279.7 MB while the world was
|
|
140
|
+
rendering at 1.9 ms p50 (526 fps)** — a gate stopping a release over a number that has never
|
|
141
|
+
been shown to cost anything. It also read 237 MB through the published shell and 279.7 MB
|
|
142
|
+
locally on the *same bundle*: ~18% run-to-run spread, which is not gate-quality precision.
|
|
143
|
+
|
|
144
|
+
A blocking gate on an unvalidated number teaches an agent to delete texture resolution chasing
|
|
145
|
+
frames it will not get — the same error as "optimising" a shadow map, and the same confusion
|
|
146
|
+
between causes and outcomes that let two worlds ship at 39 fps with everything green. **It
|
|
147
|
+
becomes blocking the day someone measures texture memory costing a frame, and not before.**
|
|
148
|
+
|
|
149
|
+
## When you hit the light ceiling, do this — not "delete lights"
|
|
150
|
+
|
|
151
|
+
The ceiling is not an instruction to make the world flat. Night Market kept its atmosphere at
|
|
152
|
+
**6 lights** while it had 21 before, and the hero plate is indistinguishable. Two techniques did
|
|
153
|
+
that:
|
|
154
|
+
|
|
155
|
+
### 1. A fixed light pool bound to emitters
|
|
156
|
+
|
|
157
|
+
Every lantern, bulb, lamp and fire becomes an **emitter** — a description of a light, not a
|
|
158
|
+
light. Each frame the emitters are ranked by distance to the camera and the nearest N are bound
|
|
159
|
+
into a fixed pool of N real lights. Shader cost is then constant no matter how many lanterns the
|
|
160
|
+
world grows.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
type Emitter = { pos: THREE.Vector3; color: THREE.Color; intensity: number; range: number };
|
|
164
|
+
|
|
165
|
+
const POOL = 6;
|
|
166
|
+
const pool = Array.from({ length: POOL }, () => {
|
|
167
|
+
const l = new THREE.PointLight(0xffffff, 0, 1);
|
|
168
|
+
l.castShadow = false; // pooled lights never cast
|
|
169
|
+
scene.add(l);
|
|
170
|
+
return l;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
function bindPool(emitters: Emitter[], camera: THREE.Camera) {
|
|
174
|
+
const cam = camera.getWorldPosition(new THREE.Vector3());
|
|
175
|
+
const ranked = emitters
|
|
176
|
+
.map((e) => ({ e, d: e.pos.distanceTo(cam) }))
|
|
177
|
+
.sort((a, b) => a.d - b.d)
|
|
178
|
+
.slice(0, POOL);
|
|
179
|
+
pool.forEach((light, i) => {
|
|
180
|
+
const hit = ranked[i];
|
|
181
|
+
if (!hit) { light.intensity = 0; return; }
|
|
182
|
+
// Fade to zero over the outer 28% of the emitter's own range, so an emitter can only
|
|
183
|
+
// enter or leave the pool while contributing nothing. A re-bind is then invisible —
|
|
184
|
+
// without this, walking down a street makes lanterns pop on and off.
|
|
185
|
+
const fade = 1 - Math.max(0, (hit.d - hit.e.range * 0.72) / (hit.e.range * 0.28));
|
|
186
|
+
light.position.copy(hit.e.pos);
|
|
187
|
+
light.color.copy(hit.e.color);
|
|
188
|
+
light.distance = hit.e.range;
|
|
189
|
+
light.intensity = hit.e.intensity * Math.min(1, Math.max(0, fade));
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Bind **after** any per-frame flicker/animation writes to the emitters, or the breathing is one
|
|
195
|
+
frame stale.
|
|
196
|
+
|
|
197
|
+
### 2. Instanced additive ground decals — this is what preserves the atmosphere
|
|
198
|
+
|
|
199
|
+
A lantern that is *not* currently in the pool still needs to read as lit. Give every emitter site
|
|
200
|
+
one instanced additive quad on the ground beneath it: a warm smear on wet asphalt, one draw call
|
|
201
|
+
for all of them.
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
const decal = new THREE.InstancedMesh(
|
|
205
|
+
new THREE.PlaneGeometry(1, 1),
|
|
206
|
+
new THREE.MeshBasicMaterial({
|
|
207
|
+
map: radialFalloffTexture, transparent: true, blending: THREE.AdditiveBlending,
|
|
208
|
+
depthWrite: false, toneMapped: false, fog: true,
|
|
209
|
+
}),
|
|
210
|
+
emitters.length, // 21 emitter sites, ONE draw call
|
|
211
|
+
);
|
|
212
|
+
decal.renderOrder = 2; // after opaque, before other transparencies
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Set each instance's matrix to the emitter's ground position, rotated flat and scaled to the pool
|
|
216
|
+
radius, and its instance colour to the emitter colour. Unlit, so it costs nothing per light —
|
|
217
|
+
and it is the pooled world's entire visual difference from the 21-light one.
|
|
218
|
+
|
|
219
|
+
Other moves, in the order they cost least:
|
|
220
|
+
- **Emissive materials.** A glowing bulb mesh reads as a light source without being one. A
|
|
221
|
+
street-lamp cone rebuilt from an emissive bulb inside its glass housing replaced four spot
|
|
222
|
+
lights.
|
|
223
|
+
- **Raise ambient/hemisphere/`environmentIntensity`** to carry what the deleted always-on lights
|
|
224
|
+
used to. Night Market went hemisphere 0.34 → 0.46, bounce 0.40 → 0.47, environment 0.35 → 0.44.
|
|
225
|
+
Verify against the original hero plate, not by argument.
|
|
226
|
+
- **Strip lights that arrive inside loaded GLBs.** An authored prop that ships its own
|
|
227
|
+
`PointLight` adds a per-pixel cost to every material in the world, invisibly and outside your
|
|
228
|
+
budget. Remove them on placement.
|
|
229
|
+
|
|
230
|
+
## Reading a perf-gate result
|
|
231
|
+
|
|
232
|
+
- **The renderer string is part of the result.** A frame time without the GPU that produced it is
|
|
233
|
+
not interpretable, and it is how you notice the ANGLE flags stopped working. If it says
|
|
234
|
+
SwiftShader, the run is void — the gate refuses rather than reporting it.
|
|
235
|
+
- **`gl.finish()` per frame is what makes the number real.** Without it the CPU queues frames
|
|
236
|
+
ahead of the GPU and you measure JS. Skyward read 562 fps without it.
|
|
237
|
+
- **p95 is the number players feel.** A world at 9 ms p50 and 90 ms p95 stutters exactly when the
|
|
238
|
+
player turns to look at something, which is exactly when they are paying attention.
|
|
239
|
+
- **One change, then re-measure the same scenario.** A batch of seven optimisations tells you
|
|
240
|
+
nothing about which one worked, and one of them is usually a regression.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# The `?perf=1` handle — ship it in every world
|
|
2
|
+
|
|
3
|
+
`helix world perf-gate` measures frame time, draw calls, triangles, passes, drawing-buffer pixels and
|
|
4
|
+
decoded texture bytes by wrapping the WebGL context, so those work on **any** bundle, including
|
|
5
|
+
one built before this file existed.
|
|
6
|
+
|
|
7
|
+
Two things it cannot get that way:
|
|
8
|
+
|
|
9
|
+
- **which** lights are in the scene — names, shadow flags, map sizes;
|
|
10
|
+
- a shadow map's configured size, as opposed to the size of some render target.
|
|
11
|
+
|
|
12
|
+
Without the handle those gates report `measured NOTHING`, which is a warning you then have to
|
|
13
|
+
carry into the QA report. With it, a failure names the light.
|
|
14
|
+
|
|
15
|
+
## Why not read the source instead
|
|
16
|
+
|
|
17
|
+
We tried. `helix world source-audit` used to count `new THREE.PointLight(` across the source tree and
|
|
18
|
+
print `PointLight×4 · SpotLight×1`. The scene it was describing contained **18 point lights and
|
|
19
|
+
4 spot lights** — four constructor call sites, all inside loops. Skyward reported 3 and had 13.
|
|
20
|
+
|
|
21
|
+
The one metric that would have caught the defect that shipped was **wrong by 4.5×**, and it was
|
|
22
|
+
wrong in the direction that passes. A census has to walk the scene that is actually rendering.
|
|
23
|
+
|
|
24
|
+
## The file
|
|
25
|
+
|
|
26
|
+
Copy this to `src/perf.ts` and call `installPerfHandle` once, after the renderer and scene exist.
|
|
27
|
+
It installs nothing unless the query flag is present, so it costs a `URLSearchParams` read during
|
|
28
|
+
play.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import type * as THREE from 'three';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Performance handle, gated behind `?perf=1`.
|
|
35
|
+
*
|
|
36
|
+
* Without this there is no way to attribute a frame to a cause: you can see that a world is
|
|
37
|
+
* slow, but not what fraction of the frame each light / shadow / material choice is worth. The
|
|
38
|
+
* handle exposes the renderer's own counters plus a light census, and lets a profiler pin a
|
|
39
|
+
* deterministic camera so two runs differ only by the one thing being ablated.
|
|
40
|
+
*/
|
|
41
|
+
export interface PerfDeps {
|
|
42
|
+
THREE: typeof THREE;
|
|
43
|
+
scene: THREE.Scene;
|
|
44
|
+
renderer: THREE.WebGLRenderer;
|
|
45
|
+
camera: THREE.Camera;
|
|
46
|
+
/** Physics body, so the profiler can park the player at a fixed, repeatable vantage point. */
|
|
47
|
+
body?: { teleport?(p: { x: number; y: number; z: number }): void };
|
|
48
|
+
/** Character facade, for camera yaw/pitch pinning. */
|
|
49
|
+
character?: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface LightCensusRow {
|
|
53
|
+
type: string;
|
|
54
|
+
name: string;
|
|
55
|
+
intensity: number;
|
|
56
|
+
castShadow: boolean;
|
|
57
|
+
mapSize: [number, number] | null;
|
|
58
|
+
/** three renders a shadow-casting point light SIX times per frame, one per cube face. */
|
|
59
|
+
passesPerFrame: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function installPerfHandle(deps: PerfDeps): void {
|
|
63
|
+
if (typeof location === 'undefined') return;
|
|
64
|
+
if (!new URLSearchParams(location.search).has('perf')) return;
|
|
65
|
+
|
|
66
|
+
const { scene, renderer } = deps;
|
|
67
|
+
|
|
68
|
+
const lights = (): LightCensusRow[] => {
|
|
69
|
+
const rows: LightCensusRow[] = [];
|
|
70
|
+
scene.traverse((o) => {
|
|
71
|
+
const l = o as THREE.Light & { shadow?: THREE.LightShadow; castShadow?: boolean };
|
|
72
|
+
if (!(l as { isLight?: boolean }).isLight) return;
|
|
73
|
+
const cast = l.castShadow === true;
|
|
74
|
+
const isPoint = l.type === 'PointLight';
|
|
75
|
+
rows.push({
|
|
76
|
+
type: l.type,
|
|
77
|
+
name: l.name || '(unnamed)',
|
|
78
|
+
intensity: l.intensity,
|
|
79
|
+
castShadow: cast,
|
|
80
|
+
mapSize: l.shadow ? [l.shadow.mapSize.x, l.shadow.mapSize.y] : null,
|
|
81
|
+
passesPerFrame: cast ? (isPoint ? 6 : 1) : 0,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
return rows;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const info = () => ({
|
|
88
|
+
render: {
|
|
89
|
+
calls: renderer.info.render.calls,
|
|
90
|
+
triangles: renderer.info.render.triangles,
|
|
91
|
+
frame: renderer.info.render.frame,
|
|
92
|
+
},
|
|
93
|
+
memory: { geometries: renderer.info.memory.geometries, textures: renderer.info.memory.textures },
|
|
94
|
+
programs: renderer.info.programs?.length ?? 0,
|
|
95
|
+
shadowPassesPerFrame: lights().reduce((a, r) => a + r.passesPerFrame, 0),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
(globalThis as unknown as Record<string, unknown>).__helixPerf = {
|
|
99
|
+
THREE: deps.THREE, scene, renderer, camera: deps.camera,
|
|
100
|
+
body: deps.body, character: deps.character, lights, info,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// src/main.ts, once, after the renderer and scene exist:
|
|
107
|
+
installPerfHandle({ THREE, scene, renderer, camera, body: character.services.body, character });
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Name your lights
|
|
111
|
+
|
|
112
|
+
`light.name = 'lantern-pool-0'` costs nothing and turns a gate failure from *"3 point lights cast
|
|
113
|
+
shadows"* into *"brazier-flame, relic-halo and lamp-3 cast shadows"*. The census prints whatever
|
|
114
|
+
is there; `(unnamed)` is a self-inflicted wound.
|
|
115
|
+
|
|
116
|
+
## What it unlocks beyond the gate
|
|
117
|
+
|
|
118
|
+
`__helixPerf` also exposes `scene`, `renderer`, `camera` and `body`, which is what makes an
|
|
119
|
+
**ablation ladder** possible: pin the player at one vantage point, run one page load per variant
|
|
120
|
+
changing exactly one thing, and the frame-time delta between two runs is attributable to that one
|
|
121
|
+
thing. That is how the light-count curve in `perf-budgets.md` was produced, and it is the only
|
|
122
|
+
honest way to answer "what is this frame actually spent on".
|
|
123
|
+
|
|
124
|
+
Rule for a ladder: **pin the pose and sweep the view identically in every run.** A variant that
|
|
125
|
+
happens to face the skybox will look like a 60% win.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Visual scorecard
|
|
2
|
+
|
|
3
|
+
Score **active-play screenshots** at the framing a player actually gets — not an idle title
|
|
4
|
+
card, not an isolated model on a turntable. Desktop and a 390×844 mobile viewport.
|
|
5
|
+
|
|
6
|
+
## Read this before you use it
|
|
7
|
+
|
|
8
|
+
This rubric is **advisory**, and saying so is the point. Four of its ten rows are backed by
|
|
9
|
+
a script that exits non-zero (marked ⛔); those are gates. The other six are you grading your
|
|
10
|
+
own screenshot, and an average you computed about your own work is not evidence. Record the
|
|
11
|
+
scores, use them to find the next pass, and never present the average as a gate.
|
|
12
|
+
|
|
13
|
+
The reference rubrics this one is adapted from gate entirely on self-report — an auditor
|
|
14
|
+
that greps the agent's prose for the string "art direction" passes any agent that types the
|
|
15
|
+
words. Everything here that can be measured has been moved into `helix world audit` and
|
|
16
|
+
`helix world source-audit` instead.
|
|
17
|
+
|
|
18
|
+
## Scale
|
|
19
|
+
|
|
20
|
+
- **0** Placeholder. Default primitives, sparse world, unreadable state, or no evidence.
|
|
21
|
+
- **1** Basic styled. Playable and themed, still obvious prototype assets or flat composition.
|
|
22
|
+
- **2** Premium stylized. Authored silhouettes, real material/detail systems, readable state.
|
|
23
|
+
- **3** Showcase. Strong direction, memorable hero and world, dense authored detail.
|
|
24
|
+
|
|
25
|
+
## Categories
|
|
26
|
+
|
|
27
|
+
| # | Category | 0 | 2 | 3 | Backed by |
|
|
28
|
+
|---|---|---|---|---|---|
|
|
29
|
+
| 1 | Art direction | no theme | theme reaches forms, materials, UI, feedback | one identity visible on every surface | — |
|
|
30
|
+
| 2 | Hero / player | default body, untouched | authored silhouette, state cues | memorable, expressive | — |
|
|
31
|
+
| 3 | Props & interactables | primitives | two+ authored forms with idle/active states | desirable and clearly valued in motion | ⛔ `composition.primitive-dominant` |
|
|
32
|
+
| 4 | World / environment | flat plane, empty arena | layered kit with fore/mid/background and scale cues | dense authored world that aids readability | ⛔ `composition.no-loaded-models` |
|
|
33
|
+
| 5 | Materials | flat colors | shared material roles, wear, trim, real maps | cohesive material language, measured texture cost | ⛔ `materials.default-plastic` |
|
|
34
|
+
| 6 | Lighting & render | default lights, or unreadable dark | tone mapping, exposure, key/fill/rim, contact | cinematic but readable, disciplined post | ⛔ `lighting.ambient-only`, `lighting.unmotivated`, `render.tonemapping` |
|
|
35
|
+
| 7 | Audio | silent | every interaction has a cue; ambience present | mixed, prioritised, spatially placed | ⛔ `audio.silent-interaction`, `audio.no-ambience` |
|
|
36
|
+
| 8 | VFX & motion | none, or random particles | event-driven: pickup, hit, fail, spawn | clarifies gameplay, stays performant | — |
|
|
37
|
+
| 9 | HUD | debug text, or under the shell chrome | genre-specific states, readable at mobile size | cohesive interface with real hierarchy | — |
|
|
38
|
+
| 10 | Performance evidence | none after visual changes | renderer counters + desktop and mobile screenshots | baseline/post numbers, named bottleneck, budgets | — |
|
|
39
|
+
|
|
40
|
+
Row 10's level 1 is *"seems fine"*. If that is what you have, write **1**.
|
|
41
|
+
|
|
42
|
+
The craft rows 4–6 grade — layering with scale cues, material roles/wear/trim, authored contrast —
|
|
43
|
+
is taught in `read_doc({ name: "world-look" })`; scoring low there is a pointer to that page, not to
|
|
44
|
+
more post-processing.
|
|
45
|
+
|
|
46
|
+
## Automatic failures
|
|
47
|
+
|
|
48
|
+
Any one of these blocks a premium claim, regardless of the average. The first five are
|
|
49
|
+
detected by script and are not negotiable; the rest you assess from the screenshot.
|
|
50
|
+
|
|
51
|
+
- ⛔ The active screenshot is primitive-dominant, or contains zero loaded models.
|
|
52
|
+
- ⛔ Authored surfaces are default white `MeshStandardMaterial`.
|
|
53
|
+
- ⛔ Lighting is ambient-only, or a local light floats unattached to an emitter.
|
|
54
|
+
- ⛔ `renderer.toneMapping` is unset, or there is no environment map.
|
|
55
|
+
*(Both lighting rows are lane-aware: a runtime-lit world — it calls `createVisualRuntime` and
|
|
56
|
+
authors `public/helix.visuals.json` — satisfies ambient-only / tone mapping / environment by
|
|
57
|
+
construction, because the visual runtime owns all three. The unattached-local-light rule
|
|
58
|
+
applies on both lanes.)*
|
|
59
|
+
- ⛔ An interaction has no sound, or a declared cue ships no file.
|
|
60
|
+
- The world is mostly stretched boxes, flat planes, or a sparse arena.
|
|
61
|
+
- One repeated silhouette does duty for every prop or every enemy.
|
|
62
|
+
- Fog, darkness, bloom or particles are hiding missing geometry rather than adding mood.
|
|
63
|
+
- The HUD overlaps the play area, clips text, or sits under the shell's top-center chrome.
|
|
64
|
+
- No active-play screenshot was captured, or the world was never driven by real input.
|
|
65
|
+
|
|
66
|
+
## Report format
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
Visual scorecard — <world>
|
|
70
|
+
1 Art direction before X / after Y evidence:
|
|
71
|
+
2 Hero / player …
|
|
72
|
+
3 Props …
|
|
73
|
+
4 World …
|
|
74
|
+
5 Materials …
|
|
75
|
+
6 Lighting & render …
|
|
76
|
+
7 Audio …
|
|
77
|
+
8 VFX & motion …
|
|
78
|
+
9 HUD …
|
|
79
|
+
10 Performance evidence …
|
|
80
|
+
Average (advisory):
|
|
81
|
+
Automatic failures remaining: <list, or none>
|
|
82
|
+
Scripted gates: world-audit exit N · source-audit exit N
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
If a category is below 2, name the exact next pass instead of declaring completion.
|