perf: Beute-3D ohne Allokationen je Frame, Materialien bleiben beim Stufenwechsel

- Federn und Kamera schreiben in wiederverwendete Objekte (keine
  Garbage je Frame)
- Glas-Materialien hängen nicht mehr an Qualitätsstufe/Umgebung;
  Spiegelung und Farbaufspaltung als Uniforms (keine Neuübersetzung von
  ~70 Shadern beim Stufenwechsel)
- Tiefenunschärfe nur im Fokus, startet ohne Unschärfe bis ein
  Fokuspunkt feststeht
- Spiegelboden als eigener reflector.ts (nach drei MeshReflectorMaterial,
  MIT) gibt Render-Targets und Blur frei
- Form-Cache mit Referenzzählung und höchstens 16 ungenutzten Formen

A/B-Messung gegen den vorherigen Stand: keine fps-Verschlechterung
außerhalb der Streuung; Ziehen im Editor auf beiden Ständen geprüft.
904 Tests (Beute, Volk, Durchsicht), Build und 3D-Budget grün.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 15:05:13 +02:00
1 parent 291ec1d7ff
commit 89a1546f15
17 files changed
+835 -96

No files matched your search

@@ -57,14 +57,13 @@ export function CameraRig({ layout, focus, onPivot, insets, gap, zoom = 1 }: Cam
const damped = motion === "full";
const dt = Math.min(delta, MAX_FRAME_SECONDS);
if (!current.current) {
current.current = { center: [...goal.center], distance: goal.distance };
current.current = { center: [goal.center[0], goal.center[1], goal.center[2]], distance: goal.distance };
} else {
// In place: one look-at point for the life of the rig (no allocation per frame).
const state = current.current;
state.center = [
stepToward(state.center[0], goal.center[0], dt, damped),
stepToward(state.center[1], goal.center[1], dt, damped),
stepToward(state.center[2], goal.center[2], dt, damped),
];
for (let axis = 0; axis < 3; axis += 1) {
state.center[axis] = stepToward(state.center[axis], goal.center[axis], dt, damped);
}
state.distance = stepToward(state.distance, goal.distance, dt, damped);
}
const { center, distance } = current.current;
@@ -73,7 +72,10 @@ export function CameraRig({ layout, focus, onPivot, insets, gap, zoom = 1 }: Cam
placeCamera(camera, center, distance);
offsetView(camera, width, height, goal.offsetY);
const settled =
distance === goal.distance && center.every((value, axis) => value === goal.center[axis]);
distance === goal.distance &&
center[0] === goal.center[0] &&
center[1] === goal.center[1] &&
center[2] === goal.center[2];
if (!settled) invalidate();
});
@@ -16,6 +16,7 @@ import { buildEdgeGeometry, buildGlassGeometry } from "./part-geometry";
import type { EdgeSegment } from "./part-geometry";
import { noRaycast, useSceneKit } from "./scene-kit";
import { setPinching, setUniform, setUniformColor } from "./scene-mutations";
import { useGlassEnvironment } from "./use-scene-effects";
// Editor drop targets (spec 9.5): every legal slot glows, the slot under the
// pointer lights up and a glass preview of the dragged part snaps into it.
@@ -57,14 +58,15 @@ function SlotMarker({ anchor, material }: { anchor: SlotAnchor; material: Shader
/** Translucent glass ghost of the dragged part at the hovered slot. */
function Preview({ anchor }: { anchor: SlotAnchor }) {
const { theme, env } = useSceneKit();
const { theme } = useSceneKit();
const [sx, sy, sz] = anchor.size;
const geometry = useMemo(
() => buildGlassGeometry([{ size: [sx, sy, sz], at: [0, 0, 0], radius: 0.03 }]),
[sx, sy, sz],
);
const edges = useMemo(() => buildEdgeGeometry(outline([sx, sy, sz], 1.2)), [sx, sy, sz]);
const glass = useMemo(() => createGlassMaterial(theme, { frost: 0.75, env }), [theme, env]);
const glass = useMemo(() => createGlassMaterial(theme, { frost: 0.75 }), [theme]);
useGlassEnvironment(glass, true);
const glow = useMemo(() => createEdgeMaterial(theme), [theme]);
useEffect(
() => () => {
@@ -11,7 +11,7 @@ import { buildEdgeGeometry, buildGlassGeometry, frameHintSpec, frameShapeSpec }
import type { PartShapeSpec } from "./part-geometry";
import { meshRaycast, noRaycast, useSceneKit } from "./scene-kit";
import { setMaterialOpacity, setUniform, setUniformMix } from "./scene-mutations";
import { useLivingEdges } from "./use-scene-effects";
import { useGlassEnvironment, useLivingEdges } from "./use-scene-effects";
import { frameRimColor } from "./frame-textures";
import type { SceneLabel } from "./scene-model";
import type { NodeHandlers } from "./stack-part";
@@ -70,32 +70,37 @@ export interface FramePlateProps {
const COMB_FROST: Partial<Record<FrameBox["type"], number>> = { DIVIDER: 0.75, FOUNDATION: 0.25 };
export function FramePlate({ frame, hostCenter, target, handlers, label }: FramePlateProps) {
const { theme, geometries, textures, motion, env, tier } = useSceneKit();
const { theme, geometries, textures, motion } = useSceneKit();
const group = useRef<Group>(null);
const spec = useMemo(() => frameShapeSpec(frame.size, frame.type), [frame.size, frame.type]);
const sizeKey = frame.size.map((value) => value.toFixed(4)).join("x");
const bars = geometries.shape(`frame-bars|${sizeKey}`, (): PartShapeSpec => ({
const barsKey = `frame-bars|${sizeKey}`;
const bars = geometries.shape(barsKey, (): PartShapeSpec => ({
slabs: spec.bars,
edges: spec.edges,
grids: [],
frost: 0,
}));
const comb = spec.comb
? geometries.shape(`frame-comb|${sizeKey}|${spec.comb.size[0].toFixed(3)}`, (): PartShapeSpec => ({
const combKey = spec.comb ? `frame-comb|${sizeKey}|${spec.comb.size[0].toFixed(3)}` : null;
const comb = combKey
? geometries.shape(combKey, (): PartShapeSpec => ({
slabs: spec.comb ? [spec.comb] : [],
edges: [],
grids: [],
frost: 0,
}))
: null;
// While mounted the plate holds its shapes, so the cache does not dispose them.
useEffect(() => geometries.retain(barsKey, bars), [geometries, barsKey, bars]);
useEffect(() => (combKey && comb ? geometries.retain(combKey, comb) : undefined), [geometries, combKey, comb]);
const texture = textures.get(frame.type);
const rimHex = frameRimColor(frame.type);
const rim = useMemo(() => (rimHex ? new Color(rimHex) : theme.edgeStrong), [rimHex, theme]);
const materials = useMemo(
() => ({
bars: createGlassMaterial(theme, { frost: 0.28, env, dispersion: tier === "high" ? 0.03 : 0 }),
comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38, env, dispersion: 0 }),
bars: createGlassMaterial(theme, { frost: 0.28 }),
comb: createGlassMaterial(theme, { frost: COMB_FROST[frame.type] ?? 0.38 }),
edges: createEdgeMaterial(theme),
typed: texture
? new MeshStandardMaterial({
@@ -113,8 +118,10 @@ export function FramePlate({ frame, hostCenter, target, handlers, label }: Frame
})
: null,
}),
[theme, texture, frame.type, env, tier],
[theme, texture, frame.type],
);
useGlassEnvironment(materials.bars, true);
useGlassEnvironment(materials.comb, false);
useEffect(
() => () => {
materials.bars.dispose();
@@ -0,0 +1,77 @@
import ReactThreeTestRenderer from "@react-three/test-renderer";
import { useRef } from "react";
import type { Group } from "three";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Vec3 } from "@/lib/hive/layout";
import { hiveLayout } from "@/lib/hive/layout";
import { inlineCatalog, templateState } from "@/lib/hive/test-utils";
import { CameraRig } from "./camera-rig";
import { springStep } from "./quality";
import { SceneKitProvider } from "./scene-kit";
import { useAnimatedNode } from "./use-animated-node";
import type { AnimatedTarget, AnimatedValues } from "./use-animated-node";
// Frame steps run up to 60 times a second for every part: they write into
// objects they own instead of allocating new ones each frame.
let renderer: Awaited<ReturnType<typeof ReactThreeTestRenderer.create>> | null = null;
afterEach(async () => {
await renderer?.unmount();
renderer = null;
});
describe("springStep", () => {
it("writes into the given state instead of returning a new object", () => {
const out = { value: 0, velocity: 0 };
const result = springStep(0, 0, 1, 1 / 60, out);
expect(result).toBe(out);
expect(out.value).toBeGreaterThan(0);
});
});
function Probe({ target, apply }: { target: AnimatedTarget; apply: (values: AnimatedValues) => void }) {
const ref = useRef<Group>(null);
useAnimatedNode(ref, target, "full", apply);
return <group ref={ref} name="probe" />;
}
describe("useAnimatedNode", () => {
it("hands the same values object to every frame while it flies and fades", async () => {
const seen: AnimatedValues[] = [];
const apply = (values: AnimatedValues) => void seen.push(values);
const rest: AnimatedTarget = { position: [0, 0, 0], scale: 1, opacity: 1, glow: 1 };
renderer = await ReactThreeTestRenderer.create(<Probe target={rest} apply={apply} />);
await renderer.advanceFrames(1, 1 / 60);
await renderer.update(<Probe target={{ position: [0, 1, 0], scale: 1.2, opacity: 0.2, glow: 2 }} apply={apply} />);
await renderer.advanceFrames(6, 1 / 60);
expect(seen.length).toBeGreaterThan(5);
expect(new Set(seen).size).toBe(1);
const node = renderer.scene.findByProps({ name: "probe" }).instance as Group;
expect(node.position.y).toBeGreaterThan(0.2);
expect(seen[0].opacity).toBeLessThan(1);
expect(seen[0].glow).toBeGreaterThan(1);
});
});
describe("CameraRig", () => {
it("moves one look-at point in place (the pivot it reports keeps its identity)", async () => {
const catalog = inlineCatalog();
const layout = hiveLayout(templateState(), catalog);
const pivots: Vec3[] = [];
const onPivot = vi.fn((center: Vec3) => void pivots.push(center));
const rig = (focus: Parameters<typeof CameraRig>[0]["focus"]) => (
<SceneKitProvider motion="full">
<CameraRig layout={layout} focus={focus} onPivot={onPivot} insets={{ top: 0, bottom: 0 }} />
</SceneKitProvider>
);
renderer = await ReactThreeTestRenderer.create(rig(null));
await renderer.advanceFrames(1, 1 / 60);
const first: Vec3 = [...pivots[pivots.length - 1]];
await renderer.update(rig({ id: "x", kind: "BOX", center: [0, 2, 0], size: [0.5, 0.3, 0.5] }));
await renderer.advanceFrames(6, 1 / 60);
expect(new Set(pivots).size).toBe(1);
expect(pivots[pivots.length - 1]).not.toEqual(first);
});
});
@@ -1,6 +1,8 @@
import ReactThreeTestRenderer from "@react-three/test-renderer";
import type { ReactThreeTest } from "@react-three/test-renderer";
import { act } from "react";
import { ShaderMaterial } from "three";
import type { Mesh } from "three";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Vec3 } from "@/lib/hive/layout";
import { hiveLayout } from "@/lib/hive/layout";
@@ -381,6 +383,48 @@ describe("HiveSceneContent drop anchors", () => {
});
});
describe("HiveSceneContent quality tier", () => {
it("keeps every glass material across a tier drop and only turns the colour split off", async () => {
const state = hiveState();
const layout = hiveLayout(state, catalog);
const store = createHiveViewStore();
const element = (tier: "high" | "low") => (
<HiveViewProvider store={store}>
<SceneKitProvider motion="off" tier={tier}>
<HiveSceneContent
layout={layout}
state={state}
catalog={catalog}
partName={(id) => partDisplayName(state, catalog, id)}
onSelectPart={vi.fn()}
onSelectFrame={vi.fn()}
idle={false}
/>
</SceneKitProvider>
</HiveViewProvider>
);
const isGlass = (material: unknown): material is ShaderMaterial =>
material instanceof ShaderMaterial && "uDispersion" in material.uniforms;
const glassOf = (renderer: Awaited<ReturnType<typeof ReactThreeTestRenderer.create>>): ShaderMaterial[] =>
renderer.scene
.findAll((node) => node.type === "Mesh")
.map((node): unknown => (node.instance as Partial<Mesh>).material)
.filter(isGlass);
const renderer = await ReactThreeTestRenderer.create(element("high"));
await act(async () => store.openBox("b1"));
const before = glassOf(renderer);
expect(before.length).toBeGreaterThan(5);
expect(before.some((material) => material.uniforms.uDispersion.value > 0)).toBe(true);
await renderer.update(element("low"));
const after = glassOf(renderer);
expect(after).toHaveLength(before.length);
after.forEach((material, index) => expect(material).toBe(before[index]));
expect(after.every((material) => material.uniforms.uDispersion.value === 0)).toBe(true);
await renderer.unmount();
});
});
describe("ModelRig", () => {
async function renderRig(motion: MotionLevel, idle: boolean) {
const pivot: Vec3 = [0, 1, 0];
@@ -0,0 +1,122 @@
import ReactThreeTestRenderer from "@react-three/test-renderer";
import { act, forwardRef, useEffect, useImperativeHandle } from "react";
import type { ReactNode } from "react";
import { WebGLRenderTarget } from "three";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createHiveViewStore, HiveViewProvider } from "../use-hive-view";
import type { HiveViewStore } from "../use-hive-view";
import { HiveStage } from "./hive-stage";
import type { StageTier } from "./quality";
import { SceneKitProvider, useSceneKit } from "./scene-kit";
import { setFocusPoint } from "./scene-mutations";
import type { Vec3 } from "@/lib/hive/layout";
interface FakeDepthOfField {
bokehScale: number;
target: { set: (x: number, y: number, z: number) => void };
}
const depthOfField = vi.hoisted(() => ({ effect: null as FakeDepthOfField | null }));
// The composer needs a real WebGL context; here its effects are plain markers.
vi.mock("@react-three/postprocessing", () => ({
EffectComposer: ({ children }: { children: ReactNode }) => <group name="composer">{children}</group>,
DepthOfField: forwardRef<FakeDepthOfField, { bokehScale: number }>(function DepthOfField({ bokehScale }, ref) {
useImperativeHandle(ref, () => {
const effect = { bokehScale, target: { set: vi.fn() } };
depthOfField.effect = effect;
return effect;
});
return <group name="depth-of-field" />;
}),
Bloom: () => null,
ToneMapping: () => null,
Vignette: () => null,
}));
// The test renderer's mock context cannot render into targets: the mirror pass is skipped.
vi.mock("./reflector", async (importOriginal) => {
const actual = await importOriginal<typeof import("./reflector")>();
return {
...actual,
createReflector: (...args: Parameters<typeof actual.createReflector>) => ({
...actual.createReflector(...args),
render: () => {},
}),
};
});
let renderer: Awaited<ReturnType<typeof ReactThreeTestRenderer.create>> | null = null;
afterEach(async () => {
await renderer?.unmount();
renderer = null;
vi.restoreAllMocks();
});
/** Stands in for the camera rig, which reports the focus point it frames. */
function FocusPoint({ point }: { point: Vec3 | null }) {
const { signals } = useSceneKit();
useEffect(() => setFocusPoint(signals, point), [signals, point]);
return null;
}
function stage(store: HiveViewStore, tier: StageTier, depthOfField: boolean, point: Vec3 | null = null) {
return (
<HiveViewProvider store={store}>
<SceneKitProvider motion="full" tier={tier}>
<HiveStage tier={tier} depthOfField={depthOfField} onQuality={vi.fn()} />
<FocusPoint point={point} />
</SceneKitProvider>
</HiveViewProvider>
);
}
const hasDepthOfField = () => renderer!.scene.findAll((node) => node.props.name === "depth-of-field").length > 0;
describe("HiveStage depth of field", () => {
it("runs only in focus mode: no blur passes in the rest view", async () => {
const store = createHiveViewStore();
renderer = await ReactThreeTestRenderer.create(stage(store, "high", true));
expect(hasDepthOfField()).toBe(false);
await act(async () => store.focusPart("fe"));
expect(hasDepthOfField()).toBe(true);
await act(async () => store.openBox(null));
expect(hasDepthOfField()).toBe(false);
});
it("blurs only once a focus point exists (none for a focus id missing from the layout)", async () => {
const store = createHiveViewStore();
renderer = await ReactThreeTestRenderer.create(stage(store, "high", true));
await act(async () => store.focusPart("not-in-layout"));
await renderer.advanceFrames(2, 1 / 60);
expect(depthOfField.effect?.bokehScale).toBe(0);
const point: Vec3 = [0, 1, 0];
await renderer.update(stage(store, "high", true, point));
await renderer.advanceFrames(1, 1 / 60);
expect(depthOfField.effect?.bokehScale).toBeGreaterThan(0);
expect(depthOfField.effect?.target.set).toHaveBeenLastCalledWith(0, 1, 0);
});
it("stays off in focus mode when the device was not measured fast", async () => {
const store = createHiveViewStore();
store.focusPart("fe");
renderer = await ReactThreeTestRenderer.create(stage(store, "high", false));
expect(hasDepthOfField()).toBe(false);
});
});
describe("HiveStage reflective floor", () => {
it("frees the reflection render targets when the tier drops", async () => {
const dispose = vi.spyOn(WebGLRenderTarget.prototype, "dispose");
const store = createHiveViewStore();
renderer = await ReactThreeTestRenderer.create(stage(store, "high", false));
// The studio cube map setup frees its own scratch target on mount.
const afterMount = dispose.mock.calls.length;
await renderer.update(stage(store, "low", false));
// Sharp and blurred reflection plus the two targets of the blur pass.
expect(dispose.mock.calls.length - afterMount).toBeGreaterThanOrEqual(4);
});
});
@@ -1,8 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { MeshReflectorMaterial } from "@react-three/drei";
import { Bloom, DepthOfField, EffectComposer, ToneMapping, Vignette } from "@react-three/postprocessing";
import { ToneMappingMode } from "postprocessing";
import type { DepthOfFieldEffect } from "postprocessing";
@@ -16,7 +15,11 @@ import {
SRGBColorSpace,
ShaderMaterial,
} from "three";
import type { Mesh } from "three";
import { focusedIdOf, useHiveViewStore } from "../use-hive-view";
import { createQualityMonitor } from "./quality";
import { createReflector } from "./reflector";
import type { ReflectorOptions } from "./reflector";
import type { QualityVerdict, StageTier } from "./quality";
import { noRaycast, useSceneKit } from "./scene-kit";
import { seededRandom } from "./seeded-random";
@@ -169,6 +172,37 @@ void main() {
}
`;
const FLOOR_REFLECTION: ReflectorOptions = {
resolution: 512,
blur: [420, 140],
mixBlur: 0.9,
mixStrength: 3.2,
mixContrast: 1.1,
mirror: 0.75,
depthScale: 0.5,
minDepthThreshold: 0.4,
maxDepthThreshold: 1.3,
color: "#060b18",
roughness: 0.9,
metalness: 0.55,
};
/** The mirror floor of the high tier; its render targets are freed when it unmounts (tier drop). */
function ReflectiveFloor() {
const gl = useThree((state) => state.gl);
const surface = useRef<Mesh>(null);
const reflector = useMemo(() => createReflector(gl, FLOOR_REFLECTION), [gl]);
useEffect(() => () => reflector.dispose(), [reflector]);
useFrame(({ scene, camera }) => {
if (surface.current) reflector.render(gl, scene, camera, surface.current);
});
return (
<mesh ref={surface} rotation-x={-Math.PI / 2} material={reflector.material} renderOrder={-3}>
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
</mesh>
);
}
function Floor({ tier }: { tier: StageTier }) {
const { theme } = useSceneKit();
const camera = useThree((state) => state.camera);
@@ -202,23 +236,7 @@ function Floor({ tier }: { tier: StageTier }) {
}
return (
<>
<mesh rotation-x={-Math.PI / 2} renderOrder={-3}>
<planeGeometry args={[FLOOR_SIZE, FLOOR_SIZE]} />
<MeshReflectorMaterial
resolution={512}
blur={[420, 140]}
mixBlur={0.9}
mixStrength={3.2}
mixContrast={1.1}
mirror={0.75}
depthScale={0.5}
minDepthThreshold={0.4}
maxDepthThreshold={1.3}
color="#060b18"
roughness={0.9}
metalness={0.55}
/>
</mesh>
<ReflectiveFloor />
{/* The grid and the distance fade sit on a layer the reflection camera does not see. */}
<mesh
rotation-x={-Math.PI / 2}
@@ -353,14 +371,25 @@ function QualityWatcher({ onVerdict }: { onVerdict: (verdict: QualityVerdict) =>
return null;
}
/** Bokeh of the depth of field in focus mode; 0 in the rest view. */
/** Bokeh of the depth of field in focus mode. */
const FOCUS_BOKEH = 2.4;
/** True while a part or an open box is in focus (only this flag re-renders, not every hover). */
function useFocusMode(): boolean {
const store = useHiveViewStore();
const focused = () => focusedIdOf(store.getState()) !== null;
return useSyncExternalStore(store.subscribe, focused, focused);
}
function Effects({ tier, depthOfField }: { tier: StageTier; depthOfField: boolean }) {
const { signals } = useSceneKit();
const dof = useRef<DepthOfFieldEffect>(null);
// Mounted for good once allowed (no composer rebuild and shader compile on the focus click);
// the focus point switches the blur on and off.
// Depth of field runs its seven passes every frame even with no blur, so it is mounted only in
// focus mode. Entering focus rebuilds the composer once (one shader compile as the flight starts);
// hopping between parts keeps it.
const focused = useFocusMode();
const blurOn = depthOfField && focused;
// No blur until the camera rig reports a focus point (first frame; a focus id missing from the layout).
useFrame(() => {
if (!dof.current) return;
if (signals.focusPoint) setEffectTarget(dof.current, signals.focusPoint);
@@ -368,7 +397,7 @@ function Effects({ tier, depthOfField }: { tier: StageTier; depthOfField: boolea
});
return (
<EffectComposer multisampling={tier === "high" ? 4 : 0}>
{depthOfField ? (
{blurOn ? (
<DepthOfField ref={dof} target={[0, 0, 0]} worldFocusRange={4} bokehScale={0} resolutionScale={0.5} />
) : (
<></>
@@ -95,6 +95,9 @@ void main() {
}
`;
/** Colour split of the refraction on the high tier. */
export const GLASS_DISPERSION = 0.03;
export interface GlassOptions {
/** 0 = clear, 1 = milky (insulation, divider board). */
frost?: number;
@@ -116,7 +119,7 @@ export function createGlassMaterial(theme: HoloTheme, options: GlassOptions = {}
uGlow: { value: 1 },
uEnv: { value: options.env ?? null },
uEnvStrength: { value: options.env ? 1 : 0 },
uDispersion: { value: options.dispersion ?? 0.03 },
uDispersion: { value: options.dispersion ?? GLASS_DISPERSION },
uSweep: { value: 0 },
uHeight: { value: 1 },
},
@@ -1,10 +1,12 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { FLOOR_GRID_DEPTH } from "@/lib/hive/layout";
import type { Vec3 } from "@/lib/hive/layout";
import type { HivePartKind } from "@/lib/hive/types";
import {
UNUSED_SHAPE_LIMIT,
buildEdgeGeometry,
buildGlassGeometry,
createGeometryCache,
feederChambers,
frameShapeSpec,
partShapeSpec,
@@ -182,3 +184,99 @@ describe("geometry builders", () => {
geometry.dispose();
});
});
describe("geometry cache", () => {
// Small specs: one slab, no edges, so the tests stay fast.
const spec = (): PartShapeSpec => ({ slabs: [{ size: [0.1, 0.1, 0.1], at: [0, 0, 0], radius: 0.01 }], edges: [], grids: [], frost: 0 });
const fill = (cache: ReturnType<typeof createGeometryCache>, prefix: string, count: number) => {
for (let index = 0; index < count; index += 1) cache.shape(`${prefix}${index}`, spec);
};
it("builds a shape once per key", () => {
const cache = createGeometryCache();
const build = vi.fn(spec);
expect(cache.shape("a", build)).toBe(cache.shape("a", build));
expect(build).toHaveBeenCalledTimes(1);
cache.dispose();
});
it("keeps a shape while a node uses it, however many others come and go", () => {
const cache = createGeometryCache();
const used = cache.shape("used", spec);
const dispose = vi.spyOn(used.glass, "dispose");
cache.retain("used", used);
fill(cache, "other", UNUSED_SHAPE_LIMIT + 5);
expect(dispose).not.toHaveBeenCalled();
expect(cache.shape("used", spec)).toBe(used);
cache.dispose();
});
it("disposes shapes no node uses once more than the limit pile up, oldest first", () => {
const cache = createGeometryCache();
const old = cache.shape("old", spec);
const dispose = vi.spyOn(old.glass, "dispose");
const release = cache.retain("old", old);
release();
fill(cache, "later", UNUSED_SHAPE_LIMIT - 1);
expect(dispose).not.toHaveBeenCalled();
fill(cache, "more", 1);
expect(dispose).toHaveBeenCalledTimes(1);
const build = vi.fn(spec);
expect(cache.shape("old", build)).not.toBe(old);
expect(build).toHaveBeenCalledTimes(1);
cache.dispose();
});
it("counts every user: the shape stays until the last one lets go", () => {
const cache = createGeometryCache();
const shared = cache.shape("shared", spec);
const dispose = vi.spyOn(shared.glass, "dispose");
const first = cache.retain("shared", shared);
const second = cache.retain("shared", shared);
first();
first();
fill(cache, "other", UNUSED_SHAPE_LIMIT + 1);
expect(dispose).not.toHaveBeenCalled();
second();
fill(cache, "again", UNUSED_SHAPE_LIMIT + 1);
expect(dispose).toHaveBeenCalledTimes(1);
cache.dispose();
});
it("tracks a shape again that was evicted between render and commit", () => {
const cache = createGeometryCache(2);
const early = cache.shape("early", spec);
fill(cache, "burst", 2);
const release = cache.retain("early", early);
const build = vi.fn(spec);
expect(cache.shape("early", build)).toBe(early);
expect(build).not.toHaveBeenCalled();
release();
cache.dispose();
});
it("disposes an orphaned shape when its last user lets go (the key was rebuilt meanwhile)", () => {
const cache = createGeometryCache(2);
const first = cache.shape("a", spec);
fill(cache, "burst", 2);
const second = cache.shape("a", spec);
expect(second).not.toBe(first);
const releaseSecond = cache.retain("a", second);
const releaseFirst = cache.retain("a", first);
const dispose = vi.spyOn(first.glass, "dispose");
releaseFirst();
expect(dispose).toHaveBeenCalledTimes(1);
expect(cache.shape("a", spec)).toBe(second);
releaseSecond();
cache.dispose();
});
it("disposes everything with the scene", () => {
const cache = createGeometryCache();
const shape = cache.shape("a", spec);
cache.retain("a", shape);
const dispose = vi.spyOn(shape.glass, "dispose");
cache.dispose();
expect(dispose).toHaveBeenCalledTimes(1);
});
});
@@ -623,25 +623,91 @@ export function disposeShape(shape: BuiltShape): void {
shape.grids.forEach((grid) => grid.geometry.dispose());
}
/** Geometries shared by every node of the same kind and size; disposed with the scene. */
/** Shapes no mounted node uses that stay cached (undo, a part moving back); older ones are disposed. */
export const UNUSED_SHAPE_LIMIT = 16;
/**
* Geometries shared by every node of the same kind and size. Nodes retain the
* shape they show; shapes nobody uses are kept for a while (most recent
* UNUSED_SHAPE_LIMIT) and then disposed, so editing a hive for a long time
* does not pile up GPU buffers. Everything is disposed with the scene.
*/
export interface GeometryCache {
/** The shape for `key`, built on first use. */
shape(key: string, build: () => PartShapeSpec): BuiltShape;
/** A mounted node uses the shape (call from an effect); returns the release. */
retain(key: string, shape: BuiltShape): () => void;
dispose(): void;
}
export function createGeometryCache(): GeometryCache {
const shapes = new Map<string, BuiltShape>();
interface CachedShape {
shape: BuiltShape;
users: number;
}
export function createGeometryCache(limit: number = UNUSED_SHAPE_LIMIT): GeometryCache {
const shapes = new Map<string, CachedShape>();
// Keys nobody uses, oldest first (a Map keeps insertion order).
const unused = new Set<string>();
/** Marks an unused shape as the most recent one and evicts beyond the limit. */
function park(key: string): void {
unused.delete(key);
unused.add(key);
while (unused.size > limit) {
const oldest = unused.values().next().value as string;
unused.delete(oldest);
const entry = shapes.get(oldest);
shapes.delete(oldest);
if (entry) disposeShape(entry.shape);
}
}
return {
shape(key, build) {
const known = shapes.get(key);
if (known) return known;
const built = buildShape(build());
shapes.set(key, built);
return built;
if (known) {
if (known.users === 0) park(key);
return known.shape;
}
const entry: CachedShape = { shape: buildShape(build()), users: 0 };
shapes.set(key, entry);
// Unused until a node retains it after its commit.
park(key);
return entry.shape;
},
retain(key, shape) {
let held = shapes.get(key);
if (!held || held.shape !== shape) {
held = { shape, users: 0 };
const cached = shapes.get(key);
if (!cached) {
// Evicted between render and commit (many shapes changed at once): track it again.
shapes.set(key, held);
} else if (cached.users === 0) {
unused.delete(key);
disposeShape(cached.shape);
shapes.set(key, held);
}
// Otherwise another node holds the cached shape: this one is an orphan, disposed on release.
}
const entry = held;
entry.users += 1;
if (shapes.get(key) === entry) unused.delete(key);
let released = false;
return () => {
if (released) return;
released = true;
entry.users -= 1;
if (entry.users > 0) return;
if (shapes.get(key) === entry) park(key);
else disposeShape(entry.shape);
};
},
dispose() {
shapes.forEach(disposeShape);
shapes.forEach((entry) => disposeShape(entry.shape));
shapes.clear();
unused.clear();
},
};
}
@@ -62,8 +62,17 @@ export interface SpringState {
velocity: number;
}
/** One frame of a damped spring toward `target` (semi-implicit Euler with small substeps). */
export function springStep(value: number, velocity: number, target: number, deltaSeconds: number): SpringState {
/**
* One frame of a damped spring toward `target` (semi-implicit Euler with small substeps).
* Writes into `out` and returns it, so frame loops can reuse one object.
*/
export function springStep(
value: number,
velocity: number,
target: number,
deltaSeconds: number,
out: SpringState = { value: 0, velocity: 0 },
): SpringState {
let x = value;
let v = velocity;
let left = Math.max(0, deltaSeconds);
@@ -73,6 +82,8 @@ export function springStep(value: number, velocity: number, target: number, delt
x += v * dt;
left -= dt;
}
if (Math.abs(target - x) < SPRING_EPSILON && Math.abs(v) < SPRING_EPSILON * 10) return { value: target, velocity: 0 };
return { value: x, velocity: v };
const settled = Math.abs(target - x) < SPRING_EPSILON && Math.abs(v) < SPRING_EPSILON * 10;
out.value = settled ? target : x;
out.velocity = settled ? 0 : v;
return out;
}
@@ -0,0 +1,55 @@
import { BlurPass } from "@react-three/drei/materials/BlurPass";
import { MeshReflectorMaterial } from "@react-three/drei/materials/MeshReflectorMaterial";
import { WebGLRenderTarget } from "three";
import type { WebGLRenderer } from "three";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createReflector } from "./reflector";
import type { ReflectorOptions } from "./reflector";
const OPTIONS: ReflectorOptions = {
resolution: 64,
blur: [100, 40],
mixBlur: 0.9,
mixStrength: 3,
mixContrast: 1.1,
mirror: 0.75,
depthScale: 0.5,
minDepthThreshold: 0.4,
maxDepthThreshold: 1.3,
color: "#060b18",
roughness: 0.9,
metalness: 0.5,
};
// The blur pass only keeps the renderer; construction needs no WebGL context.
const gl = {} as WebGLRenderer;
afterEach(() => vi.restoreAllMocks());
describe("createReflector", () => {
it("still finds drei's internal reflector material and blur pass (deep imports; check on every drei upgrade)", () => {
expect(typeof BlurPass).toBe("function");
expect(typeof MeshReflectorMaterial).toBe("function");
});
it("wires the render targets into drei's reflector material with blur and depth on", () => {
const reflector = createReflector(gl, OPTIONS);
const { material } = reflector;
expect(material).toBeInstanceOf(MeshReflectorMaterial);
expect(material.tDiffuse).not.toBeNull();
expect(material.tDepth).not.toBeNull();
expect(material.tDiffuseBlur).not.toBeNull();
expect(material.mirror).toBe(0.75);
expect(material.defines).toMatchObject({ USE_BLUR: "", USE_DEPTH: "" });
reflector.dispose();
});
it("frees both reflection targets, the blur pass targets and the material", () => {
const targets = vi.spyOn(WebGLRenderTarget.prototype, "dispose");
const reflector = createReflector(gl, OPTIONS);
const material = vi.spyOn(reflector.material, "dispose");
reflector.dispose();
expect(targets).toHaveBeenCalledTimes(4);
expect(material).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,186 @@
// Adapted from @react-three/drei MeshReflectorMaterial (MIT, Copyright (c) 2020 react-spring)
// and three.js Reflector (MIT, Copyright (c) 2010-2025 three.js authors).
import {
DepthFormat,
DepthTexture,
HalfFloatType,
LinearFilter,
Matrix4,
PerspectiveCamera,
Plane,
UnsignedShortType,
Vector3,
Vector4,
WebGLRenderTarget,
} from "three";
import type { Camera, ColorRepresentation, Object3D, Scene, WebGLRenderer } from "three";
// Internal drei paths (not part of its public API): check them on every drei upgrade
// (reflector.spec.ts imports both).
import { BlurPass } from "@react-three/drei/materials/BlurPass";
import { MeshReflectorMaterial } from "@react-three/drei/materials/MeshReflectorMaterial";
// The reflective floor of the high tier. Same technique and shader as drei's
// <MeshReflectorMaterial> (mirrored camera into a render target, depth-aware
// blur), but it owns every GPU resource and frees them in dispose(): drei's
// component never disposes its two render targets and its blur pass, so each
// drop to the low tier leaked about 6 MB of video memory.
export interface ReflectorOptions {
/** Square render-target size in pixels. */
resolution: number;
/** Blur kernel size [x, y] in pixels; [0, 0] = sharp. */
blur: [number, number];
mixBlur: number;
mixStrength: number;
mixContrast: number;
mirror: number;
depthScale: number;
minDepthThreshold: number;
maxDepthThreshold: number;
color: ColorRepresentation;
roughness: number;
metalness: number;
}
export interface Reflector {
material: MeshReflectorMaterial;
/** Renders the mirrored scene for the surface (call once per frame, before the main render). */
render(gl: WebGLRenderer, scene: Scene, camera: Camera, surface: Object3D): void;
/** Frees the render targets, the blur pass and the material. */
dispose(): void;
}
const DEPTH_TO_BLUR_RATIO_BIAS = 0.25;
export function createReflector(gl: WebGLRenderer, options: ReflectorOptions): Reflector {
const { resolution, blur } = options;
const hasBlur = blur[0] + blur[1] > 0;
const parameters = { minFilter: LinearFilter, magFilter: LinearFilter, type: HalfFloatType };
const sharp = new WebGLRenderTarget(resolution, resolution, parameters);
sharp.depthBuffer = true;
sharp.depthTexture = new DepthTexture(resolution, resolution);
sharp.depthTexture.format = DepthFormat;
sharp.depthTexture.type = UnsignedShortType;
const blurred = new WebGLRenderTarget(resolution, resolution, parameters);
const blurPass = new BlurPass({
gl,
resolution,
width: blur[0],
height: blur[1],
minDepthThreshold: options.minDepthThreshold,
maxDepthThreshold: options.maxDepthThreshold,
depthScale: options.depthScale,
depthToBlurRatioBias: DEPTH_TO_BLUR_RATIO_BIAS,
});
const textureMatrix = new Matrix4();
// Its uniforms exist only after the constructor ran: set them afterwards, one by one.
const material = new MeshReflectorMaterial();
material.color.set(options.color);
material.roughness = options.roughness;
material.metalness = options.metalness;
material.mirror = options.mirror;
material.textureMatrix = textureMatrix;
material.mixBlur = options.mixBlur;
material.tDiffuse = sharp.texture;
material.tDepth = sharp.depthTexture;
material.tDiffuseBlur = blurred.texture;
material.hasBlur = hasBlur;
material.mixStrength = options.mixStrength;
material.minDepthThreshold = options.minDepthThreshold;
material.maxDepthThreshold = options.maxDepthThreshold;
material.depthScale = options.depthScale;
material.depthToBlurRatioBias = DEPTH_TO_BLUR_RATIO_BIAS;
material.distortion = 1;
material.mixContrast = options.mixContrast;
material.defines = {
...material.defines,
...(hasBlur ? { USE_BLUR: "" } : {}),
...(options.depthScale > 0 ? { USE_DEPTH: "" } : {}),
};
// Scratch objects of the mirrored camera, reused every frame.
const virtualCamera = new PerspectiveCamera();
const reflectorPlane = new Plane();
const normal = new Vector3();
const surfacePosition = new Vector3();
const cameraPosition = new Vector3();
const rotation = new Matrix4();
const lookAt = new Vector3();
const view = new Vector3();
const target = new Vector3();
const clipPlane = new Vector4();
const q = new Vector4();
/** Points the mirrored camera; false when the camera looks at the back of the surface. */
function mirrorCamera(camera: Camera, surface: Object3D): boolean {
surfacePosition.setFromMatrixPosition(surface.matrixWorld);
cameraPosition.setFromMatrixPosition(camera.matrixWorld);
rotation.extractRotation(surface.matrixWorld);
normal.set(0, 0, 1).applyMatrix4(rotation);
view.subVectors(surfacePosition, cameraPosition);
if (view.dot(normal) > 0) return false;
view.reflect(normal).negate().add(surfacePosition);
rotation.extractRotation(camera.matrixWorld);
lookAt.set(0, 0, -1).applyMatrix4(rotation).add(cameraPosition);
target.subVectors(surfacePosition, lookAt).reflect(normal).negate().add(surfacePosition);
virtualCamera.position.copy(view);
virtualCamera.up.set(0, 1, 0).applyMatrix4(rotation).reflect(normal);
virtualCamera.lookAt(target);
virtualCamera.far = (camera as PerspectiveCamera).far ?? virtualCamera.far;
virtualCamera.updateMatrixWorld();
virtualCamera.projectionMatrix.copy(camera.projectionMatrix);
textureMatrix.set(0.5, 0, 0, 0.5, 0, 0.5, 0, 0.5, 0, 0, 0.5, 0.5, 0, 0, 0, 1);
textureMatrix.multiply(virtualCamera.projectionMatrix);
textureMatrix.multiply(virtualCamera.matrixWorldInverse);
textureMatrix.multiply(surface.matrixWorld);
// Oblique near plane on the surface (Lengyel), so nothing below it is reflected.
reflectorPlane.setFromNormalAndCoplanarPoint(normal, surfacePosition);
reflectorPlane.applyMatrix4(virtualCamera.matrixWorldInverse);
clipPlane.set(reflectorPlane.normal.x, reflectorPlane.normal.y, reflectorPlane.normal.z, reflectorPlane.constant);
const projection = virtualCamera.projectionMatrix.elements;
q.x = (Math.sign(clipPlane.x) + projection[8]) / projection[0];
q.y = (Math.sign(clipPlane.y) + projection[9]) / projection[5];
q.z = -1;
q.w = (1 + projection[10]) / projection[14];
clipPlane.multiplyScalar(2 / clipPlane.dot(q));
projection[2] = clipPlane.x;
projection[6] = clipPlane.y;
projection[10] = clipPlane.z + 1;
projection[14] = clipPlane.w;
return true;
}
return {
material,
render(renderer, scene, camera, surface) {
if (!mirrorCamera(camera, surface)) return;
const xr = renderer.xr.enabled;
const shadows = renderer.shadowMap.autoUpdate;
surface.visible = false;
renderer.xr.enabled = false;
renderer.shadowMap.autoUpdate = false;
renderer.setRenderTarget(sharp);
renderer.state.buffers.depth.setMask(true);
if (!renderer.autoClear) renderer.clear();
renderer.render(scene, virtualCamera);
if (hasBlur) blurPass.render(renderer, sharp, blurred);
renderer.xr.enabled = xr;
renderer.shadowMap.autoUpdate = shadows;
surface.visible = true;
renderer.setRenderTarget(null);
},
dispose() {
sharp.dispose();
blurred.dispose();
blurPass.renderTargetA.dispose();
blurPass.renderTargetB.dispose();
blurPass.convolutionMaterial.dispose();
blurPass.screen.geometry.dispose();
material.dispose();
},
};
}
@@ -1,6 +1,6 @@
import type { Camera, Color, Material, Mesh, Scene, ShaderMaterial, Texture, WebGLRenderer } from "three";
import type { Camera, Color, CubeTexture, Material, Mesh, Scene, ShaderMaterial, Texture, WebGLRenderer } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import { cameraPosition } from "./camera-fit";
import { cameraBasis } from "./camera-fit";
import type { SceneSignals } from "./scene-kit";
// Imperative three.js updates used from frame callbacks and effects. They live
@@ -24,15 +24,29 @@ export function setUniformMix(material: ShaderMaterial, name: string, from: Colo
if (uniform) (uniform.value as Color).copy(from).lerp(to, Math.min(1, Math.max(0, t)));
}
/** Studio reflections and colour split of a glass material (uniforms only: no new material, no recompile). */
export function setGlassEnvironment(material: ShaderMaterial, env: CubeTexture | null, dispersion: number): void {
const { uEnv, uEnvStrength, uDispersion } = material.uniforms;
if (uEnv) uEnv.value = env;
if (uEnvStrength) uEnvStrength.value = env ? 1 : 0;
if (uDispersion) uDispersion.value = dispersion;
}
export function setMaterialOpacity(material: Material, opacity: number): void {
material.opacity = opacity;
material.visible = opacity > 0.01;
}
/** Puts the camera on the fixed viewing direction at `distance` from `center`. */
/** The fixed viewing direction, computed once (placeCamera runs every frame). */
const VIEW_BACK = cameraBasis().back;
/** Puts the camera on the fixed viewing direction at `distance` from `center` (as cameraPosition). */
export function placeCamera(camera: Camera, center: Vec3, distance: number): void {
const [x, y, z] = cameraPosition(center, distance);
camera.position.set(x, y, z);
camera.position.set(
center[0] + VIEW_BACK[0] * distance,
center[1] + VIEW_BACK[1] * distance,
center[2] + VIEW_BACK[2] * distance,
);
camera.lookAt(center[0], center[1], center[2]);
}
@@ -19,7 +19,7 @@ import { useAnimatedNode } from "./use-animated-node";
import type { AnimatedValues } from "./use-animated-node";
import { GLOW } from "./use-hive-motion";
import type { NodeTarget } from "./use-hive-motion";
import { useHoverSweep, useLivingEdges } from "./use-scene-effects";
import { useGlassEnvironment, useHoverSweep, useLivingEdges } from "./use-scene-effects";
// One part of the stack or one insert: glass body, glowing edges, screens,
// an invisible hit box for pointer events, its content in focus mode and its
@@ -53,17 +53,15 @@ export interface StackPartProps {
}
export function StackPart({ part, target, focused, handlers, labels, hintFrames, children }: StackPartProps) {
const { theme, geometries, motion, env, tier } = useSceneKit();
const { theme, geometries, motion } = useSceneKit();
const group = useRef<Group>(null);
const { kind, box, state, levels } = part;
const shape = geometries.shape(shapeKey(kind, box.size, state, levels.length), () =>
partShapeSpec(kind, box.size, state, levels.length),
);
const key = shapeKey(kind, box.size, state, levels.length);
const shape = geometries.shape(key, () => partShapeSpec(kind, box.size, state, levels.length));
useEffect(() => geometries.retain(key, shape), [geometries, key, shape]);
const glass = useMemo(
() => createGlassMaterial(theme, { frost: shape.frost, env, dispersion: tier === "high" ? 0.03 : 0 }),
[theme, shape.frost, env, tier],
);
const glass = useMemo(() => createGlassMaterial(theme, { frost: shape.frost }), [theme, shape.frost]);
useGlassEnvironment(glass, true);
const edges = useMemo(() => createEdgeMaterial(theme), [theme]);
const grids = useMemo(
() => shape.grids.map((grid) => createGridMaterial(theme, { cell: grid.panel.cell, bars: grid.panel.bars })),
@@ -7,11 +7,13 @@ import type { Object3D } from "three";
import type { Vec3 } from "@/lib/hive/layout";
import type { MotionLevel } from "@/lib/motion/motion-preference";
import { springStep } from "./quality";
import type { SpringState } from "./quality";
import { SUBTLE_FADE_SECONDS, fadeToward, stepToward } from "./use-hive-motion";
// Moves a node toward its target (spec 9.8): `full` flies position and scale
// on a spring with a slight overshoot and eases the fades; `subtle` jumps in space and fades opacity, glow and look in 150 ms;
// `off` sets everything at once. Frames are only requested while moving.
// The frame step writes into objects it owns (no allocation per frame).
/** After a pause the demand loop reports a long delta; never step more than this. */
export const MAX_FRAME_SECONDS = 1 / 30;
@@ -58,6 +60,7 @@ export function useAnimatedNode(
scale: number;
scaleVelocity: number;
values: AnimatedValues;
spring: SpringState;
} | null>(null);
const latestApply = useRef(apply);
useEffect(() => {
@@ -73,51 +76,58 @@ export function useAnimatedNode(
useFrame((_, delta) => {
const node = ref.current;
if (!node) return;
const goal: AnimatedValues = { opacity: target.opacity, glow: target.glow, look: target.look ?? 0 };
const look = target.look ?? 0;
if (!current.current) {
// First frame: appear in place, no fly-in from the origin.
current.current = {
position: [...target.position],
position: [target.position[0], target.position[1], target.position[2]],
velocity: [0, 0, 0],
scale: target.scale,
scaleVelocity: 0,
values: goal,
values: { opacity: target.opacity, glow: target.glow, look },
spring: { value: 0, velocity: 0 },
};
} else {
const dt = Math.min(delta, MAX_FRAME_SECONDS);
const state = current.current;
const spring = state.spring;
for (let axis = 0; axis < 3; axis += 1) {
if (motion === "full") {
// Flights on a spring with a touch of overshoot.
for (let axis = 0; axis < 3; axis += 1) {
const next = springStep(state.position[axis], state.velocity[axis], target.position[axis], dt);
state.position[axis] = next.value;
state.velocity[axis] = next.velocity;
springStep(state.position[axis], state.velocity[axis], target.position[axis], dt, spring);
state.position[axis] = spring.value;
state.velocity[axis] = spring.velocity;
} else {
state.position[axis] = target.position[axis];
state.velocity[axis] = 0;
}
const nextScale = springStep(state.scale, state.scaleVelocity, target.scale, dt);
state.scale = nextScale.value;
state.scaleVelocity = nextScale.velocity;
}
if (motion === "full") {
springStep(state.scale, state.scaleVelocity, target.scale, dt, spring);
state.scale = spring.value;
state.scaleVelocity = spring.velocity;
} else {
state.position = [...target.position];
state.velocity = [0, 0, 0];
state.scale = target.scale;
state.scaleVelocity = 0;
}
state.values = {
opacity: stepFade(state.values.opacity, goal.opacity, dt, motion, OPACITY_RATE),
glow: stepFade(state.values.glow, goal.glow, dt, motion),
look: stepFade(state.values.look, goal.look, dt, motion),
};
const values = state.values;
values.opacity = stepFade(values.opacity, target.opacity, dt, motion, OPACITY_RATE);
values.glow = stepFade(values.glow, target.glow, dt, motion);
values.look = stepFade(values.look, look, dt, motion);
}
const state = current.current;
node.position.set(state.position[0], state.position[1], state.position[2]);
node.scale.setScalar(state.scale);
latestApply.current(state.values);
const { position, scale, values } = current.current;
node.position.set(position[0], position[1], position[2]);
node.scale.setScalar(scale);
// Always the same object: read it, do not keep it.
latestApply.current(values);
const settled =
state.position.every((value, axis) => value === target.position[axis]) &&
state.scale === target.scale &&
state.values.opacity === goal.opacity &&
state.values.glow === goal.glow &&
state.values.look === goal.look;
position[0] === target.position[0] &&
position[1] === target.position[1] &&
position[2] === target.position[2] &&
scale === target.scale &&
values.opacity === target.opacity &&
values.glow === target.glow &&
values.look === look;
if (!settled) invalidate();
});
}
@@ -1,10 +1,11 @@
"use client";
import { useRef } from "react";
import { useLayoutEffect, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import type { ShaderMaterial } from "three";
import { useSceneKit } from "./scene-kit";
import { setUniform } from "./scene-mutations";
import { GLASS_DISPERSION } from "./materials";
import { setGlassEnvironment, setUniform } from "./scene-mutations";
import { MAX_FRAME_SECONDS } from "./use-animated-node";
// Small time-driven effects. All of them only move at motion `full`; the
@@ -14,6 +15,20 @@ import { MAX_FRAME_SECONDS } from "./use-animated-node";
/** Duration of one hover sweep (shimmer and scan line), seconds. */
export const SWEEP_SECONDS = 1.1;
/**
* Studio reflections (they arrive after the first render) and the colour split
* (high tier only) as uniforms: a tier drop or the cube map keeps every glass
* material instead of rebuilding and recompiling them.
*/
export function useGlassEnvironment(material: ShaderMaterial, dispersive: boolean): void {
const { env, tier } = useSceneKit();
const invalidate = useThree((state) => state.invalidate);
useLayoutEffect(() => {
setGlassEnvironment(material, env, dispersive && tier === "high" ? GLASS_DISPERSION : 0);
invalidate();
}, [material, env, tier, dispersive, invalidate]);
}
/** Travelling light pulses along the edges. */
export function useLivingEdges(materials: ShaderMaterial[]): void {
const { motion } = useSceneKit();