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:
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();
|
||||
|
||||
Reference in new issue
Block a user