From 89a1546f15794a3b07ff75efd1e0f22d3babac3f Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 16 Oct 2026 15:05:13 +0200 Subject: [PATCH] perf: Beute-3D ohne Allokationen je Frame, Materialien bleiben beim Stufenwechsel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../components/hive/hive-scene/camera-rig.tsx | 16 +- .../hive/hive-scene/drop-targets.tsx | 6 +- .../hive/hive-scene/frame-plate.tsx | 23 ++- .../hive/hive-scene/frame-steps.spec.tsx | 77 ++++++++ .../hive-scene/hive-scene-content.spec.tsx | 44 +++++ .../hive/hive-scene/hive-stage.spec.tsx | 122 ++++++++++++ .../components/hive/hive-scene/hive-stage.tsx | 75 ++++--- .../components/hive/hive-scene/materials.ts | 5 +- .../hive/hive-scene/part-geometry.spec.ts | 100 +++++++++- .../hive/hive-scene/part-geometry.ts | 82 +++++++- .../src/components/hive/hive-scene/quality.ts | 19 +- .../hive/hive-scene/reflector.spec.ts | 55 ++++++ .../components/hive/hive-scene/reflector.ts | 186 ++++++++++++++++++ .../hive/hive-scene/scene-mutations.ts | 24 ++- .../components/hive/hive-scene/stack-part.tsx | 16 +- .../hive/hive-scene/use-animated-node.ts | 66 ++++--- .../hive/hive-scene/use-scene-effects.ts | 19 +- 17 files changed, 837 insertions(+), 98 deletions(-) create mode 100644 ApiCore-Frontend/src/components/hive/hive-scene/frame-steps.spec.tsx create mode 100644 ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.spec.tsx create mode 100644 ApiCore-Frontend/src/components/hive/hive-scene/reflector.spec.ts create mode 100644 ApiCore-Frontend/src/components/hive/hive-scene/reflector.ts diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/camera-rig.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/camera-rig.tsx index bf57977..c2b4ce3 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/camera-rig.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/camera-rig.tsx @@ -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(); }); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/drop-targets.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/drop-targets.tsx index 5eb605b..0e3e199 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/drop-targets.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/drop-targets.tsx @@ -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( () => () => { diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/frame-plate.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/frame-plate.tsx index 5698c7f..c291965 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/frame-plate.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/frame-plate.tsx @@ -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> = { 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(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(); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/frame-steps.spec.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/frame-steps.spec.tsx new file mode 100644 index 0000000..91c77c3 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/frame-steps.spec.tsx @@ -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> | 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(null); + useAnimatedNode(ref, target, "full", apply); + return ; +} + +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(); + await renderer.advanceFrames(1, 1 / 60); + await renderer.update(); + 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[0]["focus"]) => ( + + + + ); + 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); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx index 6278042..a2f9678 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx @@ -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") => ( + + + partDisplayName(state, catalog, id)} + onSelectPart={vi.fn()} + onSelectFrame={vi.fn()} + idle={false} + /> + + + ); + const isGlass = (material: unknown): material is ShaderMaterial => + material instanceof ShaderMaterial && "uDispersion" in material.uniforms; + const glassOf = (renderer: Awaited>): ShaderMaterial[] => + renderer.scene + .findAll((node) => node.type === "Mesh") + .map((node): unknown => (node.instance as Partial).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]; diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.spec.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.spec.tsx new file mode 100644 index 0000000..70b9205 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.spec.tsx @@ -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 }) => {children}, + DepthOfField: forwardRef(function DepthOfField({ bokehScale }, ref) { + useImperativeHandle(ref, () => { + const effect = { bokehScale, target: { set: vi.fn() } }; + depthOfField.effect = effect; + return effect; + }); + return ; + }), + 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(); + return { + ...actual, + createReflector: (...args: Parameters) => ({ + ...actual.createReflector(...args), + render: () => {}, + }), + }; +}); + +let renderer: Awaited> | 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 ( + + + + + + + ); +} + +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); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.tsx index f373656..6a0fb1b 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-stage.tsx @@ -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(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 ( + + + + ); +} + function Floor({ tier }: { tier: StageTier }) { const { theme } = useSceneKit(); const camera = useThree((state) => state.camera); @@ -202,23 +236,7 @@ function Floor({ tier }: { tier: StageTier }) { } return ( <> - - - - + {/* The grid and the distance fade sit on a layer the reflection camera does not see. */} 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(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 ( - {depthOfField ? ( + {blurOn ? ( ) : ( <> diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/materials.ts b/ApiCore-Frontend/src/components/hive/hive-scene/materials.ts index 5e2ea39..18fd464 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/materials.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/materials.ts @@ -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 }, }, diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.spec.ts b/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.spec.ts index 8dba128..e9c5bc0 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.spec.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.spec.ts @@ -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, 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); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.ts b/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.ts index ec7dfea..fc026e9 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/part-geometry.ts @@ -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(); +interface CachedShape { + shape: BuiltShape; + users: number; +} + +export function createGeometryCache(limit: number = UNUSED_SHAPE_LIMIT): GeometryCache { + const shapes = new Map(); + // Keys nobody uses, oldest first (a Map keeps insertion order). + const unused = new Set(); + + /** 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(); }, }; } diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/quality.ts b/ApiCore-Frontend/src/components/hive/hive-scene/quality.ts index 63ef197..c2f7952 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/quality.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/quality.ts @@ -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; } diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/reflector.spec.ts b/ApiCore-Frontend/src/components/hive/hive-scene/reflector.spec.ts new file mode 100644 index 0000000..a968064 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/reflector.spec.ts @@ -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); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/reflector.ts b/ApiCore-Frontend/src/components/hive/hive-scene/reflector.ts new file mode 100644 index 0000000..89d8d12 --- /dev/null +++ b/ApiCore-Frontend/src/components/hive/hive-scene/reflector.ts @@ -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 +// (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(); + }, + }; +} diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/scene-mutations.ts b/ApiCore-Frontend/src/components/hive/hive-scene/scene-mutations.ts index e1a1953..83cadba 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/scene-mutations.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/scene-mutations.ts @@ -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]); } diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/stack-part.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/stack-part.tsx index 3aba347..e3595da 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/stack-part.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/stack-part.tsx @@ -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(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 })), diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/use-animated-node.ts b/ApiCore-Frontend/src/components/hive/hive-scene/use-animated-node.ts index 2a39d3b..2396fdc 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/use-animated-node.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/use-animated-node.ts @@ -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; - 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; + const spring = state.spring; + for (let axis = 0; axis < 3; axis += 1) { + if (motion === "full") { + // Flights on a spring with a touch of overshoot. + 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(); }); } diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/use-scene-effects.ts b/ApiCore-Frontend/src/components/hive/hive-scene/use-scene-effects.ts index ff1f934..596cf45 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/use-scene-effects.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/use-scene-effects.ts @@ -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();