Skip to content

Commit fe33925

Browse files
committed
stress tv example test
1 parent 66be804 commit fe33925

3 files changed

Lines changed: 368 additions & 0 deletions

File tree

.claude/launch.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"version": "0.0.1",
3+
"configurations": [
4+
{
5+
"name": "examples",
6+
"runtimeExecutable": "pnpm",
7+
"runtimeArgs": ["start"],
8+
"port": 5173
9+
},
10+
{
11+
"name": "lr-examples",
12+
"runtimeExecutable": "bash",
13+
"runtimeArgs": [
14+
"-c",
15+
"cd ../lightning-renderer/examples && pnpm exec vite --port 5174 --strictPort"
16+
],
17+
"port": 5174
18+
}
19+
]
20+
}

examples/STRESS-TV.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# stress-tv — CPU vs GPU bound testing on a TV
2+
3+
A representative TV home-screen workload (rounded-rectangle cards + image
4+
thumbnails + SDF text) with remote-driven element-count control and a live
5+
on-screen read-out. Use it to find where a target device stops hitting frame
6+
rate and to classify whether that ceiling is CPU- or GPU-bound.
7+
8+
Source: [tests/stress-tv.ts](tests/stress-tv.ts)
9+
10+
## Run
11+
12+
```
13+
pnpm start:prod # prod transpile — required to validate Chrome 38 targets
14+
# then on the TV browser:
15+
http://<host>:5173/?test=stress-tv&debug=true&resolution=720
16+
```
17+
18+
`?debug=true` is what surfaces FPS / draws / quads / VAO and the per-interval GL
19+
call counts — without it you only get the bottom-left count/tier HUD.
20+
21+
### Remote controls (arrows + OK only)
22+
23+
| Key | Action |
24+
| ------------ | ----------------------------------------------------- |
25+
| Up / Down | step card count up/down the ladder (rebuilds grid) |
26+
| Left / Right | cycle scene tier (rect → +image → +text → full card) |
27+
| Enter (OK) | toggle an alpha pulse on every card (per-frame churn) |
28+
29+
Ladder: 50, 100, 200, 400, 800, 1200, 1600, 2000, 3000, 4000
30+
(scaled by `?multiplier=N`).
31+
32+
## Method
33+
34+
The grid auto-fits the screen, so **on-screen fill stays ~constant as count
35+
changes**. That decoupling is the whole point: count scales CPU per-node cost
36+
while leaving GPU fill roughly fixed, so the two bottlenecks move independently.
37+
38+
1. **Find the crossover.** Hold Up until FPS drops below target. Record N_crit
39+
per tier — that tells you which ingredient (rect / image / text) costs most.
40+
2. **Classify N_crit** with two orthogonal levers:
41+
- Lower `?resolution=540` (or `?ppr`): big FPS recovery ⇒ **GPU / fill bound**.
42+
- `?novao=true`: FPS drops and `vAttribPtr` / `enaVAA` climb ⇒ **CPU / driver bound**.
43+
- More cards at constant fill still drops FPS ⇒ **CPU / scene-graph bound**.
44+
45+
### Reading the VAO signal in the overlay
46+
47+
| Path | vAttribPtr | enaVAA | bindVAO | total GL calls |
48+
| ----------- | ---------- | ------ | ------- | -------------- |
49+
| VAO **on** | ~0 | ~0 | = draws | lower |
50+
| VAO **off** | climbs | climbs | 0 | higher |
51+
52+
The ΔFPS between the two, at a fixed count near the crossover, **is** the
53+
"how much does VAO help this TV app" answer. It grows with draw-call count
54+
(more texture switches → more attribute rebinds), so expect the image+text
55+
tiers to benefit more than rect-only.
56+
57+
## Results sheet
58+
59+
Device: ****\_\_**** Backend (overlay line 2): ****\_\_**** Target FPS: \_\_\_\_
60+
61+
### Crossover sweep (res 720, VAO on, full cards / tier 4)
62+
63+
| count | FPS | draws | quads | notes |
64+
| ----- | --- | ----- | ----- | ----- |
65+
| 200 | | | | |
66+
| 400 | | | | |
67+
| 800 | | | | |
68+
| 1200 | | | | |
69+
| 1600 | | | | |
70+
| 2000 | | | | |
71+
72+
N_crit (first count below target): **\_\_**
73+
74+
### VAO A/B (at N_crit, per tier)
75+
76+
| tier | res | VAO | FPS | GL calls | vAttribPtr | bindVAO | ΔFPS (on−off) |
77+
| ------ | --- | --- | --- | -------- | ---------- | ------- | ------------- |
78+
| 1 rect | 720 | on | | | | | |
79+
| 1 rect | 720 | off | | | | | |
80+
| 4 full | 720 | on | | | | | |
81+
| 4 full | 720 | off | | | | | |
82+
83+
### Fill lever (at N_crit, VAO on, tier 4)
84+
85+
| res | FPS | Δ vs 720 | ⇒ fill-bound? |
86+
| --- | --- | -------- | ------------- |
87+
| 720 | || |
88+
| 540 | | | |

examples/tests/stress-tv.ts

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import { type INode } from '@lightningjs/renderer';
2+
import type { ExampleSettings } from '../common/ExampleSettings.js';
3+
import lightning from '../assets/lightning.png';
4+
import rocko from '../assets/rocko.png';
5+
import testscreen from '../assets/testscreen.png';
6+
import robot from '../assets/robot/robot.png';
7+
import environment from '../assets/robot/environment.png';
8+
import elevator from '../assets/robot/elevator-background.png';
9+
10+
/**
11+
* TV CPU/GPU bound stress test.
12+
*
13+
* Renders a "real" TV home-screen grid: rounded-rectangle cards with image
14+
* thumbnails and SDF text. The grid auto-fits the screen so total fill stays
15+
* roughly constant as the card count changes — that keeps the GPU fill load
16+
* fixed while the CPU per-node cost scales with count, which is what lets you
17+
* separate the two bottlenecks.
18+
*
19+
* Run with the live overlay for FPS / draw-call / quad / VAO read-out:
20+
* ?test=stress-tv&debug=true
21+
*
22+
* A/B the VAO optimization by reloading with and without:
23+
* ?test=stress-tv&debug=true&novao=true
24+
*
25+
* Diagnose CPU vs GPU at a given count:
26+
* - lower ?resolution=540 (or ?ppr) recovers FPS -> GPU / fill bound
27+
* - ?novao=true drops FPS, vAttribPtr/enaVAA climb -> CPU / driver bound
28+
* - more cards at the same fill drops FPS -> CPU / scene-graph bound
29+
*
30+
* Remote controls (arrows + OK only):
31+
* Up / Down : step card count up / down through the ladder (rebuilds grid)
32+
* Left / Right : cycle scene tier (rect -> +image -> +text -> full card)
33+
* Enter (OK) : toggle an alpha pulse on every card (adds per-frame churn)
34+
*/
35+
36+
// Distinct image sources cycled per card so the batcher has to switch
37+
// textures — that is what makes attribute re-binding (and thus the VAO win)
38+
// actually show up in the numbers.
39+
const IMAGES = [lightning, rocko, testscreen, robot, environment, elevator];
40+
41+
// Card-count ladder. Up/Down move one rung so the whole range is reachable in
42+
// a handful of remote presses from the couch.
43+
const COUNT_LADDER = [50, 100, 200, 400, 800, 1200, 1600, 2000, 3000, 4000];
44+
45+
const TIER_NAMES = [
46+
'1: rounded rect only',
47+
'2: + image',
48+
'3: + image + title',
49+
'4: full card (img + title + subtitle)',
50+
];
51+
52+
const APP_W = 1920;
53+
const APP_H = 1080;
54+
55+
const randomTitle = (length: number): string => {
56+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz';
57+
let out = '';
58+
for (let i = 0; i < length; i++) {
59+
out += chars.charAt(Math.floor(Math.random() * chars.length));
60+
}
61+
return out;
62+
};
63+
64+
export default async function ({
65+
renderer,
66+
testRoot,
67+
perfMultiplier,
68+
}: ExampleSettings) {
69+
renderer.createNode({
70+
x: 0,
71+
y: 0,
72+
w: APP_W,
73+
h: APP_H,
74+
color: 0x0f172aff, // dark slate background (0xRRGGBBAA)
75+
parent: testRoot,
76+
});
77+
78+
// Container the whole grid hangs off so a rebuild is one destroy + refill.
79+
let gridRoot = renderer.createNode({
80+
x: 0,
81+
y: 0,
82+
w: APP_W,
83+
h: APP_H,
84+
parent: testRoot,
85+
});
86+
87+
let cards: INode[] = [];
88+
let pulsing = false;
89+
90+
// Start near a rung scaled by ?multiplier so automation/large runs can bias up.
91+
let ladderIndex = 2; // 200
92+
let tier = 3; // full card by default — the realistic TV workload
93+
94+
// Bottom-left so it never collides with the top-left ?debug=true overlay.
95+
const hud = renderer.createTextNode({
96+
x: 20,
97+
y: APP_H - 150,
98+
fontFamily: 'Ubuntu',
99+
textRendererOverride: 'sdf', // never Canvas here — it re-rasterizes per edit and OOMs TVs
100+
fontSize: 22,
101+
color: 0xffffffff,
102+
text: '',
103+
zIndex: 1000,
104+
parent: testRoot,
105+
});
106+
107+
const currentCount = (): number => {
108+
const base = COUNT_LADDER[ladderIndex]!;
109+
return Math.max(1, Math.round(base * perfMultiplier));
110+
};
111+
112+
const updateHud = (): void => {
113+
hud.text =
114+
`cards ${currentCount()} tier ${TIER_NAMES[tier]} pulse ${
115+
pulsing === true ? 'on' : 'off'
116+
}\n` +
117+
'Up/Down count Left/Right tier OK pulse (add ?debug=true for FPS/draws/quads/VAO)';
118+
};
119+
120+
const buildGrid = (): void => {
121+
// Tear down the previous grid in one shot — destroy() recurses to children.
122+
gridRoot.destroy();
123+
cards = [];
124+
125+
gridRoot = renderer.createNode({
126+
x: 0,
127+
y: 0,
128+
w: APP_W,
129+
h: APP_H,
130+
parent: testRoot,
131+
});
132+
133+
const count = currentCount();
134+
135+
// Auto-fit a near-square cell grid across the screen so on-screen fill
136+
// stays ~constant regardless of count.
137+
const cols = Math.max(1, Math.ceil(Math.sqrt(count * (APP_W / APP_H))));
138+
const rows = Math.max(1, Math.ceil(count / cols));
139+
const cellW = APP_W / cols;
140+
const cellH = APP_H / rows;
141+
const gap = Math.min(cellW, cellH) * 0.08;
142+
const cardW = cellW - gap;
143+
const cardH = cellH - gap;
144+
const radius = Math.min(24, Math.min(cardW, cardH) * 0.12);
145+
const fontSize = Math.max(8, Math.min(28, cardH * 0.16));
146+
147+
for (let i = 0; i < count; i++) {
148+
const col = i % cols;
149+
const row = (i / cols) | 0;
150+
const x = col * cellW + gap * 0.5;
151+
const y = row * cellH + gap * 0.5;
152+
153+
// Tier 1+: rounded-rectangle card background (the borderRadius cost).
154+
const card = renderer.createNode({
155+
x,
156+
y,
157+
w: cardW,
158+
h: cardH,
159+
color: 0x1e293bff, // slate-800 (0xRRGGBBAA)
160+
shader: renderer.createShader('Rounded', { radius }),
161+
parent: gridRoot,
162+
});
163+
cards.push(card);
164+
165+
// Tier 2+: image thumbnail filling most of the card.
166+
if (tier >= 1) {
167+
renderer.createNode({
168+
x: gap * 0.5,
169+
y: gap * 0.5,
170+
w: cardW - gap,
171+
h: cardH * 0.6,
172+
src: IMAGES[i % IMAGES.length]!,
173+
parent: card,
174+
});
175+
}
176+
177+
// Tier 3+: SDF title.
178+
if (tier >= 2) {
179+
renderer.createTextNode({
180+
x: gap,
181+
y: cardH * 0.62,
182+
fontFamily: 'Ubuntu',
183+
textRendererOverride: 'sdf',
184+
fontSize,
185+
color: 0xffffffff,
186+
text: randomTitle(8),
187+
parent: card,
188+
});
189+
}
190+
191+
// Tier 4: SDF subtitle.
192+
if (tier >= 3) {
193+
renderer.createTextNode({
194+
x: gap,
195+
y: cardH * 0.62 + fontSize * 1.3,
196+
fontFamily: 'Ubuntu',
197+
textRendererOverride: 'sdf',
198+
fontSize: fontSize * 0.8,
199+
color: 0x94a3b8ff, // slate-400 (0xRRGGBBAA)
200+
text: randomTitle(12),
201+
parent: card,
202+
});
203+
}
204+
}
205+
206+
if (pulsing === true) {
207+
startPulse();
208+
}
209+
210+
updateHud();
211+
console.log(
212+
`stress-tv: ${count} cards, tier ${tier + 1}, ${cols}x${rows} grid`,
213+
);
214+
};
215+
216+
const startPulse = (): void => {
217+
for (let i = 0; i < cards.length; i++) {
218+
cards[i]!.animate(
219+
{ alpha: 0.4 },
220+
{ duration: 1000, loop: true, easing: 'ease-in-out' },
221+
).start();
222+
}
223+
};
224+
225+
window.addEventListener('keydown', (event) => {
226+
const key = event.key;
227+
228+
if (key === 'ArrowUp') {
229+
if (ladderIndex < COUNT_LADDER.length - 1) {
230+
ladderIndex++;
231+
buildGrid();
232+
}
233+
return;
234+
}
235+
if (key === 'ArrowDown') {
236+
if (ladderIndex > 0) {
237+
ladderIndex--;
238+
buildGrid();
239+
}
240+
return;
241+
}
242+
if (key === 'ArrowRight') {
243+
tier = (tier + 1) % TIER_NAMES.length;
244+
buildGrid();
245+
return;
246+
}
247+
if (key === 'ArrowLeft') {
248+
tier = (tier + TIER_NAMES.length - 1) % TIER_NAMES.length;
249+
buildGrid();
250+
return;
251+
}
252+
if (key === 'Enter') {
253+
pulsing = pulsing !== true;
254+
buildGrid();
255+
return;
256+
}
257+
});
258+
259+
buildGrid();
260+
}

0 commit comments

Comments
 (0)