Skip to content

Commit 9ceb6af

Browse files
committed
Prevent landing content flash during particle loading
1 parent 347ea00 commit 9ceb6af

3 files changed

Lines changed: 142 additions & 199 deletions

File tree

app/actions/public/remix-landing/components/particle-canvas.tsx

Lines changed: 86 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,21 @@ import { RestBaker } from "../engine/rest-baker.ts";
1212
import { getMorphBlend, type MorphBlend } from "../engine/morph.ts";
1313
import { createModelTexture } from "../engine/model-texture.ts";
1414
import type { ModelData } from "../engine/model-loader.ts";
15-
import type { Preset, ShaderId, SystemSettings } from "../engine/types.ts";
15+
import { presets } from "../engine/presets.ts";
16+
import {
17+
DEFAULT_SETTINGS,
18+
type Preset,
19+
type ShaderId,
20+
type SystemSettings,
21+
} from "../engine/types.ts";
1622
import { clamp, clamp01, lerp } from "../utils/math.ts";
1723
import { reducedMotion } from "../utils/reduced-motion.ts";
1824

19-
// Must match `LOADING_SCREEN_MIN_MS` in `landing-enhancements.tsx`.
20-
const PARTICLE_INTRO_DELAY_S = 1;
25+
const PARTICLE_INTRO_DURATION_S = 3.5;
26+
const BRAND_MODE_SETTINGS: SystemSettings = {
27+
...DEFAULT_SETTINGS,
28+
colorMode: 2,
29+
};
2130
const DEFAULT_CAM_POS: [number, number, number] = [0, 30, 80];
2231
const DEFAULT_CAM_TARGET: [number, number, number] = [0, 0, 0];
2332
const CAM_LERP_SPEED = 0.025;
@@ -35,10 +44,6 @@ const SHADER_ID_TO_INT: Record<ShaderId, number> = {
3544
racetrackCar: 4,
3645
};
3746

38-
function resolveShaderInt(preset: Preset): number {
39-
return SHADER_ID_TO_INT[preset.shaderId];
40-
}
41-
4247
const shellStyles = css({
4348
position: "fixed",
4449
inset: "0",
@@ -52,15 +57,6 @@ const canvasStyles = css({
5257
height: "100%",
5358
});
5459

55-
type PresetRuntimeData = {
56-
presets: Preset[];
57-
controls: number[][];
58-
shaderInts: number[];
59-
racetrackIndex: number;
60-
driveIndex: number;
61-
driveCarPosY: number;
62-
};
63-
6460
function setDesiredCameraInto(
6561
presets: Preset[],
6662
morphValue: number,
@@ -117,39 +113,31 @@ function buildInitialControls(preset: Preset): number[] {
117113
return controls;
118114
}
119115

120-
function getControlInitial(preset: Preset, id: string, fallback = 0): number {
121-
return (
122-
preset.controls.find((control) => control.id === id)?.initial ?? fallback
123-
);
124-
}
116+
const DRIVE_INDEX = presets.findIndex((preset) => preset.name === "Drive");
117+
const PRESET_RUNTIME_DATA = {
118+
controls: presets.map(buildInitialControls),
119+
shaderInts: presets.map((preset) => SHADER_ID_TO_INT[preset.shaderId]),
120+
racetrackIndex: presets.findIndex((preset) => preset.name === "Racetrack"),
121+
driveIndex: DRIVE_INDEX,
122+
driveCarPosY:
123+
DRIVE_INDEX >= 0
124+
? (presets[DRIVE_INDEX].controls.find(
125+
(control) => control.id === "_carPosY",
126+
)?.initial ?? 0)
127+
: 0,
128+
};
125129

126-
function buildPresetRuntimeData(presets: Preset[]): PresetRuntimeData {
127-
const driveIndex = presets.findIndex((preset) => preset.name === "Drive");
128-
return {
129-
presets,
130-
controls: presets.map(buildInitialControls),
131-
shaderInts: presets.map(resolveShaderInt),
132-
racetrackIndex: presets.findIndex((preset) => preset.name === "Racetrack"),
133-
driveIndex,
134-
driveCarPosY:
135-
driveIndex >= 0
136-
? getControlInitial(presets[driveIndex], "_carPosY", 0)
137-
: 0,
138-
};
139-
}
130+
type ParticleCanvasProps = {
131+
brandGradientMode: boolean;
132+
morphValueRef: { current: number };
133+
modelData: (ModelData | undefined)[];
134+
labelsRef: { current: ProjectedLabel[] };
135+
labelOpacityRef: { current: number };
136+
onFirstFrame: () => Promise<void>;
137+
onError: (error: unknown) => void;
138+
};
140139

141-
export function ParticleCanvas(
142-
handle: Handle<{
143-
settings: SystemSettings;
144-
presets: Preset[];
145-
morphValueRef: { current: number };
146-
modelData: (ModelData | undefined)[];
147-
labelsRef: { current: ProjectedLabel[] };
148-
labelOpacityRef: { current: number };
149-
onReady: () => void;
150-
onError: (error: unknown) => void;
151-
}>,
152-
) {
140+
export function ParticleCanvas(handle: Handle<ParticleCanvasProps>) {
153141
let containerEl: HTMLDivElement | undefined;
154142
let canvasEl: HTMLCanvasElement | undefined;
155143
let engine: Engine | null = null;
@@ -159,9 +147,11 @@ export function ParticleCanvas(
159147
const appliedModelSlots = new Set<number>();
160148
let frameId = 0;
161149
let startTime = 0;
150+
let reveal: Promise<void> | null = null;
151+
let introStartTime: number | null = null;
152+
let introFinished = false;
162153
let frozenTime: number | null = null;
163154
let previousNearest = -1;
164-
let hasReportedReady = false;
165155
let initFailed = false;
166156
const labelControlMgr = new ControlManager();
167157
const desiredCameraPos = new Vector3();
@@ -174,19 +164,6 @@ export function ParticleCanvas(
174164
const scratchControlsB = [0, 0, 0, 0, 0, 0, 0, 0];
175165
const scratchLabelControls = [0, 0, 0, 0, 0, 0, 0, 0];
176166
const morphBlend: MorphBlend = { fromIndex: 0, toIndex: 0, blend: 0 };
177-
let presetRuntimeData: PresetRuntimeData | null = null;
178-
let currentProps:
179-
| {
180-
settings: SystemSettings;
181-
presets: Preset[];
182-
morphValueRef: { current: number };
183-
modelData: (ModelData | undefined)[];
184-
labelsRef: { current: ProjectedLabel[] };
185-
labelOpacityRef: { current: number };
186-
onReady: () => void;
187-
onError: (error: unknown) => void;
188-
}
189-
| undefined;
190167

191168
let mouseNormX = 0;
192169
let mouseNormY = 0;
@@ -266,12 +243,6 @@ export function ParticleCanvas(
266243
},
267244
});
268245

269-
function getPresetRuntimeData(presets: Preset[]) {
270-
if (presetRuntimeData?.presets === presets) return presetRuntimeData;
271-
presetRuntimeData = buildPresetRuntimeData(presets);
272-
return presetRuntimeData;
273-
}
274-
275246
function disposeScene() {
276247
cancelAnimationFrame(frameId);
277248
if (particles && engine) {
@@ -295,18 +266,17 @@ export function ParticleCanvas(
295266
});
296267

297268
function syncModelTextures() {
298-
if (!restBaker || !currentProps) return;
269+
if (!restBaker) return;
299270

300-
for (const preset of currentProps.presets) {
271+
for (const preset of presets) {
301272
if (
302273
preset.modelUrl == null ||
303274
preset.modelSlot == null ||
304275
appliedModelSlots.has(preset.modelSlot)
305276
)
306277
continue;
307278

308-
const model =
309-
currentProps.modelData[currentProps.presets.indexOf(preset)];
279+
const model = handle.props.modelData[presets.indexOf(preset)];
310280
if (!model) continue;
311281

312282
restBaker.setModelTexture(
@@ -319,26 +289,20 @@ export function ParticleCanvas(
319289
}
320290

321291
function maybeInit() {
322-
if (initFailed || engine || !containerEl || !canvasEl || !currentProps) {
323-
return;
324-
}
292+
if (initFailed || engine || !containerEl || !canvasEl) return;
325293

326294
try {
295+
const settings = handle.props.brandGradientMode
296+
? BRAND_MODE_SETTINGS
297+
: DEFAULT_SETTINGS;
327298
engine = new Engine();
328-
engine.init(canvasEl, containerEl, currentProps.settings);
299+
engine.init(canvasEl, containerEl, settings);
329300

330-
restBaker = new RestBaker(
331-
engine.renderer,
332-
currentProps.settings.particleCount,
333-
);
334-
restBaker.setCount(currentProps.settings.particleCount);
301+
restBaker = new RestBaker(engine.renderer, settings.particleCount);
302+
restBaker.setCount(settings.particleCount);
335303

336304
particles = new ParticleSystem();
337-
particles.init(
338-
engine.scene,
339-
currentProps.settings.particleCount,
340-
currentProps.settings.pointSize,
341-
);
305+
particles.init(engine.scene, settings.particleCount, settings.pointSize);
342306
// Bind the baker's MRT texture refs to the draw material once. Three
343307
// caches the references; subsequent bake() calls update the GL backing
344308
// in place.
@@ -350,10 +314,7 @@ export function ParticleCanvas(
350314
);
351315
syncModelTextures();
352316

353-
mouseSim = new MouseSim(
354-
engine.renderer,
355-
currentProps.settings.particleCount,
356-
);
317+
mouseSim = new MouseSim(engine.renderer, settings.particleCount);
357318
mouseSim.setRestTextures(
358319
restBaker.getPosTexture(0),
359320
restBaker.getPosTexture(1),
@@ -366,8 +327,8 @@ export function ParticleCanvas(
366327

367328
startTime = performance.now() / 1000;
368329
setDesiredCameraInto(
369-
currentProps.presets,
370-
currentProps.morphValueRef.current,
330+
presets,
331+
handle.props.morphValueRef.current,
371332
desiredCameraPos,
372333
desiredCameraTarget,
373334
);
@@ -377,18 +338,17 @@ export function ParticleCanvas(
377338
// samples populated rest textures. This also forces the baker's
378339
// RawShaderMaterial to compile here, hiding the link/upload cost from
379340
// the first animate() frame.
380-
const initialPresetData = getPresetRuntimeData(currentProps.presets);
381341
const initialIndex = Math.min(
382-
Math.max(0, Math.floor(currentProps.morphValueRef.current)),
383-
initialPresetData.presets.length - 1,
342+
Math.max(0, Math.floor(handle.props.morphValueRef.current)),
343+
presets.length - 1,
384344
);
385345
copyControlsInto(
386-
initialPresetData.controls[initialIndex],
346+
PRESET_RUNTIME_DATA.controls[initialIndex],
387347
scratchControlsA,
388348
);
389349
restBaker.bake(
390350
0,
391-
initialPresetData.shaderInts[initialIndex],
351+
PRESET_RUNTIME_DATA.shaderInts[initialIndex],
392352
scratchControlsA,
393353
0,
394354
);
@@ -400,14 +360,12 @@ export function ParticleCanvas(
400360
} catch (error) {
401361
initFailed = true;
402362
disposeScene();
403-
currentProps.onError(error);
363+
handle.props.onError(error);
404364
return;
405365
}
406366

407367
const animate = () => {
408-
if (!engine || !particles || !restBaker || !mouseSim || !currentProps) {
409-
return;
410-
}
368+
if (!engine || !particles || !restBaker || !mouseSim) return;
411369

412370
const now = performance.now();
413371
const time = now / 1000 - startTime;
@@ -417,14 +375,15 @@ export function ParticleCanvas(
417375
const dtSeconds =
418376
lastFrameNow === 0 ? 1 / 60 : (now - lastFrameNow) / 1000;
419377
lastFrameNow = now;
420-
const settings = currentProps.settings;
421-
const presets = currentProps.presets;
422-
const presetData = getPresetRuntimeData(presets);
423-
const morphValue = currentProps.morphValueRef.current;
378+
const settings = handle.props.brandGradientMode
379+
? BRAND_MODE_SETTINGS
380+
: DEFAULT_SETTINGS;
381+
const presetData = PRESET_RUNTIME_DATA;
382+
const morphValue = handle.props.morphValueRef.current;
424383
const reduceMotion = reducedMotion.current;
425384

426385
if (reduceMotion) {
427-
frozenTime ??= Math.max(time, PARTICLE_INTRO_DELAY_S + 3.5);
386+
frozenTime ??= Math.max(time, PARTICLE_INTRO_DURATION_S);
428387
} else {
429388
frozenTime = null;
430389
}
@@ -471,10 +430,19 @@ export function ParticleCanvas(
471430

472431
particles.setColorMode(settings.colorMode);
473432
particles.setDof(settings.dofAmount, settings.dofFocus);
474-
const introTime = Math.max(0, visualTime - PARTICLE_INTRO_DELAY_S);
475-
particles.setIntroProgress(
476-
reduceMotion ? 1.5 : Math.min(introTime / 3.5, 1.5),
477-
);
433+
if (reduceMotion && introStartTime !== null) {
434+
// Once reduced motion has skipped the intro, do not replay it if the
435+
// preference changes while this component is mounted.
436+
introFinished = true;
437+
}
438+
const introTime =
439+
introStartTime === null ? 0 : Math.max(0, visualTime - introStartTime);
440+
const introProgress =
441+
reduceMotion || introFinished
442+
? 1.5
443+
: Math.min(introTime / PARTICLE_INTRO_DURATION_S, 1.5);
444+
introFinished ||= introProgress >= 1.5;
445+
particles.setIntroProgress(introProgress);
478446
particles.setTime(visualTime);
479447

480448
const maxValue = presets.length - 1;
@@ -689,7 +657,7 @@ export function ParticleCanvas(
689657
}
690658

691659
projectLabelsInto(
692-
currentProps.labelsRef.current,
660+
handle.props.labelsRef.current,
693661
nearestPreset,
694662
labelControlMgr,
695663
activeCtrls,
@@ -700,29 +668,28 @@ export function ParticleCanvas(
700668
);
701669

702670
const distFromNearest = Math.abs(morphValue - nearest);
703-
currentProps.labelOpacityRef.current = Math.max(
671+
handle.props.labelOpacityRef.current = Math.max(
704672
0,
705673
1 - distFromNearest * 4,
706674
);
707675
} else {
708-
currentProps.labelsRef.current.length = 0;
709-
currentProps.labelOpacityRef.current = 0;
676+
handle.props.labelsRef.current.length = 0;
677+
handle.props.labelOpacityRef.current = 0;
710678
}
711679

712680
engine.render(time);
713-
if (!hasReportedReady) {
714-
hasReportedReady = true;
715-
currentProps.onReady();
716-
}
681+
reveal ??= handle.props.onFirstFrame().then(() => {
682+
if (!handle.signal.aborted) {
683+
introStartTime = performance.now() / 1000 - startTime;
684+
}
685+
});
717686
frameId = requestAnimationFrame(animate);
718687
};
719688

720689
frameId = requestAnimationFrame(animate);
721690
}
722691

723692
return () => {
724-
currentProps = handle.props;
725-
726693
if (engine) {
727694
syncModelTextures();
728695
}

0 commit comments

Comments
 (0)