Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39b996eb31 | |||
| 134c0aecb7 | |||
| b144dc1c18 | |||
| 69c720b86b | |||
| 1b57a25e5f | |||
| f6db7d74e2 | |||
| a1798aecb3 | |||
| 3b07f40f2d | |||
| 27416143e3 | |||
| a2a491bd5c | |||
| da7d66e1fd | |||
| 5faf4b4197 | |||
| bee0c7f223 | |||
| 216d29ae59 | |||
| e13cf1e4c7 |
@@ -41,29 +41,42 @@ The octree visualization reads the live `Octree` instance from `World`. The
|
|||||||
mesh uses `depthTest: false` and a high `renderOrder`, so cells stay visible
|
mesh uses `depthTest: false` and a high `renderOrder`, so cells stay visible
|
||||||
through opaque geometry.
|
through opaque geometry.
|
||||||
|
|
||||||
## Shadow rendering intermittence (open investigation)
|
## Shadow rendering intermittence
|
||||||
|
|
||||||
Shadows occasionally fail to render on initial load even though the
|
Shadows occasionally failed to render on initial load and could disappear
|
||||||
`Lighting` configuration runs to completion (verified through diagnostic logs).
|
mid-session even though the `Lighting` configuration ran to completion. The
|
||||||
The issue is not deterministic across runs with identical config. Suspected
|
fix has two layers:
|
||||||
contributors:
|
|
||||||
|
|
||||||
- WebGL context restoration timing (`webglcontextrestored` rebinds shadow map
|
### Per-frame refresh (steady state)
|
||||||
state in `src/pages/page.tsx`).
|
|
||||||
- First-frame shadow map being rendered before any mesh has its
|
|
||||||
`castShadow`/`receiveShadow` flag set; `autoUpdate=true` should fix it on the
|
|
||||||
next frame, but a single dropped frame is still visible at very first paint.
|
|
||||||
- HMR/state interactions in dev mode that do not occur in production builds.
|
|
||||||
|
|
||||||
Mitigations already applied:
|
The sun follows the camera, so its world matrix is dirty every frame. With
|
||||||
|
`shadow.autoUpdate` alone, three.js can skip the shadow map re-render on a
|
||||||
|
frame where the matrix update has happened but the renderer's internal dirty
|
||||||
|
tracking does not pick it up. To prevent that, `Lighting.useFrame` sets
|
||||||
|
`sun.shadow.needsUpdate = true` after the per-frame matrix updates. Shadow
|
||||||
|
config is centralized in `src/data/world/lightingConfig.ts` (`bias=0`,
|
||||||
|
`normalBias=0`, `cameraSize=95`).
|
||||||
|
|
||||||
- Shadow config centralized in `src/data/world/lightingConfig.ts`
|
### Mount-time shadow map reallocation (`useShadowMapWarmup`)
|
||||||
(`bias=0`, `normalBias=0`, `cameraSize=95`, matching the historically working
|
|
||||||
values from `develop`).
|
|
||||||
- Late-suspension Suspense boundaries in `World.tsx` to prevent global scene
|
|
||||||
remounts that would re-run shadow setup mid-load.
|
|
||||||
|
|
||||||
If the issue reproduces in production, capture a screenshot plus the
|
The merged static map and other GLTFs mount imperatively after `Lighting`,
|
||||||
`[diag]`-style logs from `useOctreeGraphNode`, `Lighting`, and `GameMapCollision`
|
so the shadow render target ends up linked to a renderer state that pre-dates
|
||||||
to confirm whether the third configuration pass is happening (which would
|
the final scene. Materials compiled at that point bake a "no shadow map"
|
||||||
indicate a remaining suspending hook outside the existing Suspense boundaries).
|
permutation into their shader program and silently fail to render shadows
|
||||||
|
until a WebGL context-restore cycle (the kind triggered by Chrome DevTools
|
||||||
|
in `?debug` runs) reallocates everything.
|
||||||
|
|
||||||
|
`src/hooks/three/useShadowMapWarmup.ts` replays that cycle programmatically
|
||||||
|
without the cost of a full context loss. It runs a `useFrame` watchdog that
|
||||||
|
samples the scene mesh count every 6 frames; once the count has been stable
|
||||||
|
for ~1 s (or after a 5 s safety cap), it:
|
||||||
|
|
||||||
|
1. Disposes the directional light shadow map and nulls it. three.js
|
||||||
|
reallocates the render target on the next render at the configured
|
||||||
|
`mapSize`.
|
||||||
|
2. Marks every material's `needsUpdate = true`, forcing a shader recompile
|
||||||
|
that rebinds every program to the freshly created shadow sampler.
|
||||||
|
3. Forces a single shadow pass and invalidates the renderer.
|
||||||
|
|
||||||
|
The watchdog runs once per mount and adds a single traversal every 6 frames
|
||||||
|
during the warmup window, after which it self-terminates.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,10 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Box3, BufferAttribute, BufferGeometry, Color } from "three";
|
import { Box3, BufferAttribute, BufferGeometry } from "three";
|
||||||
import type { Octree } from "three-stdlib";
|
import type { Octree } from "three-stdlib";
|
||||||
|
import {
|
||||||
|
LA_FABRIK_CENTER,
|
||||||
|
isInsideLaFabrikFootprint,
|
||||||
|
} from "@/data/world/laFabrikConfig";
|
||||||
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
|
import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
|
||||||
|
|
||||||
interface DebugOctreeVisualizationProps {
|
interface DebugOctreeVisualizationProps {
|
||||||
@@ -11,8 +15,19 @@ interface OctreeNodeBox {
|
|||||||
box: Box3;
|
box: Box3;
|
||||||
depth: number;
|
depth: number;
|
||||||
triangleCount: number;
|
triangleCount: number;
|
||||||
|
isLeaf: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CollectOptions {
|
||||||
|
minDepth: number;
|
||||||
|
maxDepth: number;
|
||||||
|
leavesOnly: boolean;
|
||||||
|
fabrikOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FABRIK_FILTER_PADDING = 1.5;
|
||||||
|
const FABRIK_FILTER_VERTICAL = 8;
|
||||||
|
|
||||||
const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
|
const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[1, 3],
|
[1, 3],
|
||||||
@@ -28,22 +43,50 @@ const BOX_VERTEX_INDEX_PAIRS: ReadonlyArray<readonly [number, number]> = [
|
|||||||
[3, 7],
|
[3, 7],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function boxIntersectsFabrik(box: Box3): boolean {
|
||||||
|
if (box.max.y < LA_FABRIK_CENTER[1] - FABRIK_FILTER_VERTICAL) return false;
|
||||||
|
if (box.min.y > LA_FABRIK_CENTER[1] + FABRIK_FILTER_VERTICAL) return false;
|
||||||
|
|
||||||
|
// Sample box corners + center on XZ plane against the rotated fabrik footprint.
|
||||||
|
const samples: ReadonlyArray<readonly [number, number]> = [
|
||||||
|
[box.min.x, box.min.z],
|
||||||
|
[box.min.x, box.max.z],
|
||||||
|
[box.max.x, box.min.z],
|
||||||
|
[box.max.x, box.max.z],
|
||||||
|
[(box.min.x + box.max.x) * 0.5, (box.min.z + box.max.z) * 0.5],
|
||||||
|
];
|
||||||
|
for (const [x, z] of samples) {
|
||||||
|
if (isInsideLaFabrikFootprint(x, z, FABRIK_FILTER_PADDING)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function collectOctreeBoxes(
|
function collectOctreeBoxes(
|
||||||
node: Octree,
|
node: Octree,
|
||||||
maxDepth: number,
|
options: CollectOptions,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
acc: OctreeNodeBox[] = [],
|
acc: OctreeNodeBox[] = [],
|
||||||
): OctreeNodeBox[] {
|
): OctreeNodeBox[] {
|
||||||
if (depth > maxDepth) return acc;
|
if (depth > options.maxDepth) return acc;
|
||||||
|
|
||||||
acc.push({
|
const isLeaf = node.subTrees.length === 0;
|
||||||
box: node.box,
|
const passesDepth = depth >= options.minDepth;
|
||||||
depth,
|
const passesLeafFilter = !options.leavesOnly || isLeaf;
|
||||||
triangleCount: node.triangles.length,
|
const hasTriangles = node.triangles.length > 0;
|
||||||
});
|
const passesFabrikFilter =
|
||||||
|
!options.fabrikOnly || boxIntersectsFabrik(node.box);
|
||||||
|
|
||||||
|
if (passesDepth && passesLeafFilter && hasTriangles && passesFabrikFilter) {
|
||||||
|
acc.push({
|
||||||
|
box: node.box,
|
||||||
|
depth,
|
||||||
|
triangleCount: node.triangles.length,
|
||||||
|
isLeaf,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const sub of node.subTrees) {
|
for (const sub of node.subTrees) {
|
||||||
collectOctreeBoxes(sub, maxDepth, depth + 1, acc);
|
collectOctreeBoxes(sub, options, depth + 1, acc);
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
@@ -55,17 +98,12 @@ function buildOctreeLineGeometry(
|
|||||||
const positionsBuffer = new Float32Array(
|
const positionsBuffer = new Float32Array(
|
||||||
nodes.length * BOX_VERTEX_INDEX_PAIRS.length * 2 * 3,
|
nodes.length * BOX_VERTEX_INDEX_PAIRS.length * 2 * 3,
|
||||||
);
|
);
|
||||||
const colorsBuffer = new Float32Array(
|
|
||||||
nodes.length * BOX_VERTEX_INDEX_PAIRS.length * 2 * 3,
|
|
||||||
);
|
|
||||||
|
|
||||||
const corners: [number, number, number][] = Array.from({ length: 8 }, () => [
|
const corners: [number, number, number][] = Array.from({ length: 8 }, () => [
|
||||||
0, 0, 0,
|
0, 0, 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let positionsOffset = 0;
|
let positionsOffset = 0;
|
||||||
let colorsOffset = 0;
|
|
||||||
const colorHelper = new Color();
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
const { min, max } = node.box;
|
const { min, max } = node.box;
|
||||||
@@ -79,9 +117,6 @@ function buildOctreeLineGeometry(
|
|||||||
corners[6] = [min.x, max.y, max.z];
|
corners[6] = [min.x, max.y, max.z];
|
||||||
corners[7] = [max.x, max.y, max.z];
|
corners[7] = [max.x, max.y, max.z];
|
||||||
|
|
||||||
const hue = (node.depth * 0.13) % 1;
|
|
||||||
colorHelper.setHSL(hue, 0.85, 0.55);
|
|
||||||
|
|
||||||
for (const [a, b] of BOX_VERTEX_INDEX_PAIRS) {
|
for (const [a, b] of BOX_VERTEX_INDEX_PAIRS) {
|
||||||
const ca = corners[a]!;
|
const ca = corners[a]!;
|
||||||
const cb = corners[b]!;
|
const cb = corners[b]!;
|
||||||
@@ -91,19 +126,11 @@ function buildOctreeLineGeometry(
|
|||||||
positionsBuffer[positionsOffset++] = cb[0];
|
positionsBuffer[positionsOffset++] = cb[0];
|
||||||
positionsBuffer[positionsOffset++] = cb[1];
|
positionsBuffer[positionsOffset++] = cb[1];
|
||||||
positionsBuffer[positionsOffset++] = cb[2];
|
positionsBuffer[positionsOffset++] = cb[2];
|
||||||
|
|
||||||
colorsBuffer[colorsOffset++] = colorHelper.r;
|
|
||||||
colorsBuffer[colorsOffset++] = colorHelper.g;
|
|
||||||
colorsBuffer[colorsOffset++] = colorHelper.b;
|
|
||||||
colorsBuffer[colorsOffset++] = colorHelper.r;
|
|
||||||
colorsBuffer[colorsOffset++] = colorHelper.g;
|
|
||||||
colorsBuffer[colorsOffset++] = colorHelper.b;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const geometry = new BufferGeometry();
|
const geometry = new BufferGeometry();
|
||||||
geometry.setAttribute("position", new BufferAttribute(positionsBuffer, 3));
|
geometry.setAttribute("position", new BufferAttribute(positionsBuffer, 3));
|
||||||
geometry.setAttribute("color", new BufferAttribute(colorsBuffer, 3));
|
|
||||||
return geometry;
|
return geometry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,14 +138,23 @@ export function DebugOctreeVisualization({
|
|||||||
octree,
|
octree,
|
||||||
}: DebugOctreeVisualizationProps): React.JSX.Element | null {
|
}: DebugOctreeVisualizationProps): React.JSX.Element | null {
|
||||||
const showOctree = useDebugVisualsStore((state) => state.showOctree);
|
const showOctree = useDebugVisualsStore((state) => state.showOctree);
|
||||||
|
const minDepth = useDebugVisualsStore((state) => state.octreeMinDepth);
|
||||||
const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth);
|
const maxDepth = useDebugVisualsStore((state) => state.octreeMaxDepth);
|
||||||
|
const leavesOnly = useDebugVisualsStore((state) => state.octreeLeavesOnly);
|
||||||
|
const opacity = useDebugVisualsStore((state) => state.octreeOpacity);
|
||||||
|
const fabrikOnly = useDebugVisualsStore((state) => state.octreeFabrikOnly);
|
||||||
|
|
||||||
const geometry = useMemo(() => {
|
const geometry = useMemo(() => {
|
||||||
if (!octree || !showOctree) return null;
|
if (!octree || !showOctree) return null;
|
||||||
const boxes = collectOctreeBoxes(octree, maxDepth);
|
const boxes = collectOctreeBoxes(octree, {
|
||||||
|
minDepth,
|
||||||
|
maxDepth,
|
||||||
|
leavesOnly,
|
||||||
|
fabrikOnly,
|
||||||
|
});
|
||||||
if (boxes.length === 0) return null;
|
if (boxes.length === 0) return null;
|
||||||
return buildOctreeLineGeometry(boxes);
|
return buildOctreeLineGeometry(boxes);
|
||||||
}, [maxDepth, octree, showOctree]);
|
}, [fabrikOnly, leavesOnly, maxDepth, minDepth, octree, showOctree]);
|
||||||
|
|
||||||
if (!geometry) return null;
|
if (!geometry) return null;
|
||||||
|
|
||||||
@@ -126,11 +162,11 @@ export function DebugOctreeVisualization({
|
|||||||
<lineSegments frustumCulled={false} renderOrder={999}>
|
<lineSegments frustumCulled={false} renderOrder={999}>
|
||||||
<primitive object={geometry} attach="geometry" />
|
<primitive object={geometry} attach="geometry" />
|
||||||
<lineBasicMaterial
|
<lineBasicMaterial
|
||||||
vertexColors
|
color="#22d3ee"
|
||||||
depthTest={false}
|
depthTest={false}
|
||||||
depthWrite={false}
|
depthWrite={false}
|
||||||
transparent
|
transparent
|
||||||
opacity={0.85}
|
opacity={opacity}
|
||||||
/>
|
/>
|
||||||
</lineSegments>
|
</lineSegments>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,87 +1,24 @@
|
|||||||
import { Suspense, useEffect, useMemo, useRef } from "react";
|
import { Suspense } from "react";
|
||||||
import { Canvas, useFrame } from "@react-three/fiber";
|
import { Canvas } from "@react-three/fiber";
|
||||||
import { useGLTF } from "@react-three/drei";
|
import { TalkieModel } from "@/components/ui/talkie/TalkieModel";
|
||||||
import * as THREE from "three";
|
import { TalkieSignalLines } from "@/components/ui/talkie/TalkieSignalLines";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useTalkieDialogueOverlayState } from "@/hooks/ui/useTalkieDialogueOverlayState";
|
||||||
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
|
||||||
|
|
||||||
const TALKIE_MODEL_PATH = "/models/talkie/model.gltf";
|
|
||||||
const TALKIE_REVEAL_STEPS = new Set([
|
|
||||||
"reveal",
|
|
||||||
"await-ebike-mount",
|
|
||||||
"ebike-intro-ride",
|
|
||||||
"ebike-breakdown",
|
|
||||||
"completed",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function TalkieModel(): React.JSX.Element {
|
|
||||||
const { scene } = useGLTF(TALKIE_MODEL_PATH);
|
|
||||||
const model = useMemo(() => scene.clone(true), [scene]);
|
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
model.traverse((child) => {
|
|
||||||
if (child instanceof THREE.Mesh) {
|
|
||||||
child.castShadow = false;
|
|
||||||
child.receiveShadow = false;
|
|
||||||
child.frustumCulled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [model]);
|
|
||||||
|
|
||||||
useFrame(({ clock }) => {
|
|
||||||
if (!groupRef.current) return;
|
|
||||||
|
|
||||||
const t = clock.getElapsedTime();
|
|
||||||
groupRef.current.rotation.z = Math.sin(t * 22) * 0.025;
|
|
||||||
groupRef.current.position.y = Math.sin(t * 6) * 0.012;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group ref={groupRef}>
|
|
||||||
<primitive
|
|
||||||
object={model}
|
|
||||||
position={[0, -0.18, 0]}
|
|
||||||
rotation={[0.18, Math.PI, -0.08]}
|
|
||||||
scale={1.45}
|
|
||||||
/>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TalkieSignalLines(): React.JSX.Element {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className="talkie-dialogue-overlay__signals"
|
|
||||||
viewBox="0 0 120 160"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M34 20 C52 44 16 66 34 92 C48 112 22 128 30 146" />
|
|
||||||
<path d="M68 12 C92 44 50 70 70 104 C84 130 48 142 52 154" />
|
|
||||||
<path d="M100 8 C124 42 82 76 100 112 C112 136 74 150 78 158" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TalkieDialogueOverlay(): React.JSX.Element | null {
|
export function TalkieDialogueOverlay(): React.JSX.Element | null {
|
||||||
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
|
const { isNarratorDialogue, isVisible } = useTalkieDialogueOverlayState();
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
|
||||||
const introStep = useGameStore((state) => state.intro.currentStep);
|
|
||||||
const isAfterReveal =
|
|
||||||
mainState !== "intro" || TALKIE_REVEAL_STEPS.has(introStep);
|
|
||||||
const isNarratorDialogue = activeSubtitle?.speaker === "Narrateur";
|
|
||||||
|
|
||||||
if (!isAfterReveal || !isNarratorDialogue) return null;
|
if (!isVisible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className="talkie-dialogue-overlay talkie-dialogue-overlay--raised"
|
className={`talkie-dialogue-overlay${isNarratorDialogue ? " talkie-dialogue-overlay--active" : ""}`}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<TalkieSignalLines />
|
{isNarratorDialogue ? <TalkieSignalLines side="left" /> : null}
|
||||||
|
{isNarratorDialogue ? <TalkieSignalLines side="right" /> : null}
|
||||||
<div className="talkie-dialogue-overlay__model-frame">
|
<div className="talkie-dialogue-overlay__model-frame">
|
||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [0, 0, 4.2], zoom: 78 }}
|
camera={{ position: [0, 0, 4.2], zoom: 56 }}
|
||||||
dpr={[1, 1.5]}
|
dpr={[1, 1.5]}
|
||||||
gl={{ alpha: true, antialias: true }}
|
gl={{ alpha: true, antialias: true }}
|
||||||
orthographic
|
orthographic
|
||||||
@@ -89,12 +26,10 @@ export function TalkieDialogueOverlay(): React.JSX.Element | null {
|
|||||||
<ambientLight intensity={2.5} />
|
<ambientLight intensity={2.5} />
|
||||||
<directionalLight position={[2, 3, 4]} intensity={2.8} />
|
<directionalLight position={[2, 3, 4]} intensity={2.8} />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<TalkieModel />
|
<TalkieModel active={isNarratorDialogue} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
useGLTF.preload(TALKIE_MODEL_PATH);
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import { useGLTF } from "@react-three/drei";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
const TALKIE_MODEL_PATH = "/models/talkie/model.gltf";
|
||||||
|
const TALKIE_REST_Y = -1.55;
|
||||||
|
const TALKIE_ACTIVE_Y = -0.38;
|
||||||
|
const TALKIE_BASE_ROTATION: Vector3Tuple = [0.08, -0.52, -0.04];
|
||||||
|
const TALKIE_FLOAT_ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(2.2);
|
||||||
|
const TALKIE_FLOAT_Y_AMPLITUDE = 0.055;
|
||||||
|
|
||||||
|
interface TalkieModelProps {
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TalkieModel({ active }: TalkieModelProps): React.JSX.Element {
|
||||||
|
const { scene } = useGLTF(TALKIE_MODEL_PATH);
|
||||||
|
const model = useMemo(() => scene.clone(true), [scene]);
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const floatRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
model.traverse((child) => {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.castShadow = false;
|
||||||
|
child.receiveShadow = false;
|
||||||
|
child.frustumCulled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [model]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const group = groupRef.current;
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
gsap.killTweensOf(group.position);
|
||||||
|
gsap.to(group.position, {
|
||||||
|
y: active ? TALKIE_ACTIVE_Y : TALKIE_REST_Y,
|
||||||
|
duration: active ? 0.72 : 0.5,
|
||||||
|
ease: active ? "power3.out" : "power2.out",
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
gsap.killTweensOf(group.position);
|
||||||
|
};
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!floatRef.current) return;
|
||||||
|
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
floatRef.current.position.y = Math.sin(t * 1.2) * TALKIE_FLOAT_Y_AMPLITUDE;
|
||||||
|
|
||||||
|
floatRef.current.rotation.x =
|
||||||
|
TALKIE_BASE_ROTATION[0] +
|
||||||
|
Math.sin(t * 0.7) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
|
||||||
|
floatRef.current.rotation.y =
|
||||||
|
TALKIE_BASE_ROTATION[1] +
|
||||||
|
Math.sin(t * 0.55) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
|
||||||
|
floatRef.current.rotation.z =
|
||||||
|
TALKIE_BASE_ROTATION[2] +
|
||||||
|
Math.sin(t * 0.8) * TALKIE_FLOAT_ROTATION_AMPLITUDE;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef} position={[0, TALKIE_REST_Y, 0]}>
|
||||||
|
<group ref={floatRef} rotation={TALKIE_BASE_ROTATION}>
|
||||||
|
<primitive
|
||||||
|
object={model}
|
||||||
|
position={[0, -2.45, 0]}
|
||||||
|
rotation={[0, -1, 0]}
|
||||||
|
scale={1.2}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useGLTF.preload(TALKIE_MODEL_PATH);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
interface TalkieSignalLinesProps {
|
||||||
|
side: "left" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TalkieSignalLines({
|
||||||
|
side,
|
||||||
|
}: TalkieSignalLinesProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`talkie-dialogue-overlay__signals talkie-dialogue-overlay__signals--${side}`}
|
||||||
|
viewBox="0 0 90 120"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M18 48 C30 58 30 72 18 82" />
|
||||||
|
<path d="M34 34 C56 52 56 78 34 96" />
|
||||||
|
<path d="M52 20 C84 46 84 84 52 110" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,10 +23,14 @@ export const MAP_OCTREE_COLLISION_BOXES = {
|
|||||||
} as const satisfies Record<string, MapOctreeCollisionBox>;
|
} as const satisfies Record<string, MapOctreeCollisionBox>;
|
||||||
|
|
||||||
export const LA_FABRIK_INTERIOR_COLLISION_BOXES = [
|
export const LA_FABRIK_INTERIOR_COLLISION_BOXES = [
|
||||||
{
|
// NOTE: removed — this thin wall (size [0.2, 1.94, 3.71]) sat at x≈-6.93 and
|
||||||
center: [-6.9351, 2.278, -0.0001],
|
// sealed the doorway despite the geometry having a hole there. The fabrik
|
||||||
size: [0.2, 1.94, 3.711],
|
// mesh octree already provides the surrounding wall collision, so this
|
||||||
},
|
// proxy was both redundant and bug-causing.
|
||||||
|
// {
|
||||||
|
// center: [-6.9351, 2.278, -0.0001],
|
||||||
|
// size: [0.2, 1.94, 3.711],
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
center: [0.8026, 0.719, -3.639],
|
center: [0.8026, 0.719, -3.639],
|
||||||
size: [4.346, 1.108, 1.181],
|
size: [4.346, 1.108, 1.181],
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ import { useDebugVisualsStore } from "@/managers/stores/useDebugVisualsStore";
|
|||||||
|
|
||||||
export function useDebugVisualsDebug(): void {
|
export function useDebugVisualsDebug(): void {
|
||||||
useDebugFolder("Debug", (folder) => {
|
useDebugFolder("Debug", (folder) => {
|
||||||
|
const state = useDebugVisualsStore.getState();
|
||||||
const controls = {
|
const controls = {
|
||||||
showPlayerModel: useDebugVisualsStore.getState().showPlayerModel,
|
showPlayerModel: state.showPlayerModel,
|
||||||
showOctree: useDebugVisualsStore.getState().showOctree,
|
showOctree: state.showOctree,
|
||||||
octreeMaxDepth: useDebugVisualsStore.getState().octreeMaxDepth,
|
octreeMinDepth: state.octreeMinDepth,
|
||||||
|
octreeMaxDepth: state.octreeMaxDepth,
|
||||||
|
octreeLeavesOnly: state.octreeLeavesOnly,
|
||||||
|
octreeOpacity: state.octreeOpacity,
|
||||||
|
octreeFabrikOnly: state.octreeFabrikOnly,
|
||||||
};
|
};
|
||||||
|
|
||||||
folder
|
folder
|
||||||
@@ -23,11 +28,39 @@ export function useDebugVisualsDebug(): void {
|
|||||||
useDebugVisualsStore.getState().setShowOctree(value);
|
useDebugVisualsStore.getState().setShowOctree(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
folder
|
||||||
|
.add(controls, "octreeLeavesOnly")
|
||||||
|
.name("Octree Leaves Only")
|
||||||
|
.onChange((value: boolean) => {
|
||||||
|
useDebugVisualsStore.getState().setOctreeLeavesOnly(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
folder
|
||||||
|
.add(controls, "octreeMinDepth", 0, 10, 1)
|
||||||
|
.name("Octree Min Depth")
|
||||||
|
.onChange((value: number) => {
|
||||||
|
useDebugVisualsStore.getState().setOctreeMinDepth(value);
|
||||||
|
});
|
||||||
|
|
||||||
folder
|
folder
|
||||||
.add(controls, "octreeMaxDepth", 0, 10, 1)
|
.add(controls, "octreeMaxDepth", 0, 10, 1)
|
||||||
.name("Octree Max Depth")
|
.name("Octree Max Depth")
|
||||||
.onChange((value: number) => {
|
.onChange((value: number) => {
|
||||||
useDebugVisualsStore.getState().setOctreeMaxDepth(value);
|
useDebugVisualsStore.getState().setOctreeMaxDepth(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
folder
|
||||||
|
.add(controls, "octreeOpacity", 0.05, 1, 0.05)
|
||||||
|
.name("Octree Opacity")
|
||||||
|
.onChange((value: number) => {
|
||||||
|
useDebugVisualsStore.getState().setOctreeOpacity(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
folder
|
||||||
|
.add(controls, "octreeFabrikOnly")
|
||||||
|
.name("Octree Fabrik Only")
|
||||||
|
.onChange((value: boolean) => {
|
||||||
|
useDebugVisualsStore.getState().setOctreeFabrikOnly(value);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import type { Object3D } from "three";
|
import { type Object3D } from "three";
|
||||||
import { Octree } from "three-stdlib";
|
import { Octree } from "three-stdlib";
|
||||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import {
|
||||||
|
Material,
|
||||||
|
Mesh,
|
||||||
|
type DirectionalLight,
|
||||||
|
type Scene,
|
||||||
|
type WebGLRenderer,
|
||||||
|
} from "three";
|
||||||
|
|
||||||
|
interface UseShadowMapWarmupOptions {
|
||||||
|
/** Light whose shadow map should be reallocated once the scene stabilizes. */
|
||||||
|
light: React.RefObject<DirectionalLight | null>;
|
||||||
|
scene: Scene;
|
||||||
|
gl: WebGLRenderer;
|
||||||
|
invalidate: () => void;
|
||||||
|
/** Frames the mesh count must remain unchanged to consider the scene stable. */
|
||||||
|
stableFramesThreshold?: number;
|
||||||
|
/** Hard cap on how long we keep watching, in frames (~5s @60fps). */
|
||||||
|
safetyCapFrames?: number;
|
||||||
|
/** Sample mesh count every N frames to keep the traversal cost negligible. */
|
||||||
|
sampleEveryFrames?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useShadowMapWarmup({
|
||||||
|
light,
|
||||||
|
scene,
|
||||||
|
gl,
|
||||||
|
invalidate,
|
||||||
|
stableFramesThreshold = 60,
|
||||||
|
safetyCapFrames = 300,
|
||||||
|
sampleEveryFrames = 6,
|
||||||
|
}: UseShadowMapWarmupOptions): void {
|
||||||
|
const meshCountRef = useRef(0);
|
||||||
|
const stableFramesRef = useRef(0);
|
||||||
|
const watchFramesRef = useRef(0);
|
||||||
|
const doneRef = useRef(false);
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
if (doneRef.current || !light.current) return;
|
||||||
|
|
||||||
|
watchFramesRef.current += 1;
|
||||||
|
|
||||||
|
if (watchFramesRef.current % sampleEveryFrames === 0) {
|
||||||
|
let meshCount = 0;
|
||||||
|
scene.traverse((object) => {
|
||||||
|
if (object instanceof Mesh) meshCount += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (meshCount !== meshCountRef.current) {
|
||||||
|
meshCountRef.current = meshCount;
|
||||||
|
stableFramesRef.current = 0;
|
||||||
|
} else {
|
||||||
|
stableFramesRef.current += sampleEveryFrames;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stableEnough = stableFramesRef.current >= stableFramesThreshold;
|
||||||
|
const safetyCapReached = watchFramesRef.current >= safetyCapFrames;
|
||||||
|
if (!stableEnough && !safetyCapReached) return;
|
||||||
|
|
||||||
|
doneRef.current = true;
|
||||||
|
reallocateShadowMap(light.current);
|
||||||
|
invalidateAllMaterials(scene);
|
||||||
|
forceShadowPass(gl, scene, light.current);
|
||||||
|
invalidate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reallocateShadowMap(light: DirectionalLight): void {
|
||||||
|
const shadowMap = light.shadow.map;
|
||||||
|
if (!shadowMap) return;
|
||||||
|
|
||||||
|
shadowMap.dispose();
|
||||||
|
light.shadow.map = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateAllMaterials(scene: Scene): void {
|
||||||
|
const seen = new Set<Material>();
|
||||||
|
scene.traverse((object) => {
|
||||||
|
if (!(object instanceof Mesh)) return;
|
||||||
|
const materials = Array.isArray(object.material)
|
||||||
|
? object.material
|
||||||
|
: [object.material];
|
||||||
|
for (const material of materials) {
|
||||||
|
if (!material || seen.has(material)) continue;
|
||||||
|
seen.add(material);
|
||||||
|
material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceShadowPass(
|
||||||
|
gl: WebGLRenderer,
|
||||||
|
scene: Scene,
|
||||||
|
light: DirectionalLight,
|
||||||
|
): void {
|
||||||
|
scene.updateMatrixWorld(true);
|
||||||
|
light.target.updateMatrixWorld(true);
|
||||||
|
light.updateMatrixWorld(true);
|
||||||
|
light.shadow.camera.updateMatrixWorld(true);
|
||||||
|
light.shadow.camera.updateProjectionMatrix();
|
||||||
|
light.shadow.needsUpdate = true;
|
||||||
|
gl.shadowMap.needsUpdate = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { GAME_STEPS } from "@/data/game/gameStateConfig";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||||
|
|
||||||
|
const TALKIE_FIRST_VISIBLE_STEP = "reveal";
|
||||||
|
const TALKIE_FIRST_VISIBLE_STEP_INDEX = GAME_STEPS.indexOf(
|
||||||
|
TALKIE_FIRST_VISIBLE_STEP,
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TalkieDialogueOverlayState {
|
||||||
|
isNarratorDialogue: boolean;
|
||||||
|
isVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTalkieDialogueOverlayState(): TalkieDialogueOverlayState {
|
||||||
|
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
|
||||||
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
|
const introStep = useGameStore((state) => state.intro.currentStep);
|
||||||
|
const introStepIndex = GAME_STEPS.indexOf(introStep);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isNarratorDialogue: activeSubtitle?.speaker === "Narrateur",
|
||||||
|
isVisible:
|
||||||
|
mainState !== "intro" ||
|
||||||
|
introStepIndex >= TALKIE_FIRST_VISIBLE_STEP_INDEX,
|
||||||
|
};
|
||||||
|
}
|
||||||
+20
-17
@@ -1240,27 +1240,24 @@ canvas {
|
|||||||
/* Dialogue talkie */
|
/* Dialogue talkie */
|
||||||
.talkie-dialogue-overlay {
|
.talkie-dialogue-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: clamp(12px, 2.2vw, 28px);
|
left: 0;
|
||||||
bottom: clamp(24px, 7vh, 76px);
|
bottom: 0;
|
||||||
z-index: 16;
|
z-index: 16;
|
||||||
width: clamp(120px, 13vw, 190px);
|
width: clamp(190px, 18vw, 300px);
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translateY(0);
|
|
||||||
transition: transform 180ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.talkie-dialogue-overlay--raised {
|
|
||||||
transform: translateY(-10px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.talkie-dialogue-overlay__model-frame {
|
.talkie-dialogue-overlay__model-frame {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
animation: talkie-radio-shake 1s ease-in-out infinite;
|
|
||||||
filter: drop-shadow(0 16px 22px rgba(0, 0, 0, 0.55));
|
filter: drop-shadow(0 16px 22px rgba(0, 0, 0, 0.55));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.talkie-dialogue-overlay--active .talkie-dialogue-overlay__model-frame {
|
||||||
|
animation: talkie-radio-shake 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.talkie-dialogue-overlay__model-frame canvas {
|
.talkie-dialogue-overlay__model-frame canvas {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
@@ -1268,16 +1265,25 @@ canvas {
|
|||||||
|
|
||||||
.talkie-dialogue-overlay__signals {
|
.talkie-dialogue-overlay__signals {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -26%;
|
top: 52%;
|
||||||
bottom: 34%;
|
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
width: 58%;
|
width: 38%;
|
||||||
height: 78%;
|
height: 52%;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
animation: talkie-signal-pulse 1s ease-in-out infinite;
|
animation: talkie-signal-pulse 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.talkie-dialogue-overlay__signals--left {
|
||||||
|
right: 62%;
|
||||||
|
transform: translateY(-50%) scaleX(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.talkie-dialogue-overlay__signals--right {
|
||||||
|
left: 62%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
.talkie-dialogue-overlay__signals path {
|
.talkie-dialogue-overlay__signals path {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: rgba(235, 244, 255, 0.9);
|
stroke: rgba(235, 244, 255, 0.9);
|
||||||
@@ -1327,18 +1333,15 @@ canvas {
|
|||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
opacity: 0.28;
|
opacity: 0.28;
|
||||||
transform: translate3d(-4px, 4px, 0) scale(0.92);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
18%,
|
18%,
|
||||||
38% {
|
38% {
|
||||||
opacity: 0.95;
|
opacity: 0.95;
|
||||||
transform: translate3d(0, 0, 0) scale(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
60% {
|
60% {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
transform: translate3d(4px, -6px, 0) scale(1.05);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ interface DebugVisualsStore {
|
|||||||
setShowOctree: (value: boolean) => void;
|
setShowOctree: (value: boolean) => void;
|
||||||
octreeMaxDepth: number;
|
octreeMaxDepth: number;
|
||||||
setOctreeMaxDepth: (value: number) => void;
|
setOctreeMaxDepth: (value: number) => void;
|
||||||
|
octreeMinDepth: number;
|
||||||
|
setOctreeMinDepth: (value: number) => void;
|
||||||
|
octreeLeavesOnly: boolean;
|
||||||
|
setOctreeLeavesOnly: (value: boolean) => void;
|
||||||
|
octreeOpacity: number;
|
||||||
|
setOctreeOpacity: (value: number) => void;
|
||||||
|
octreeFabrikOnly: boolean;
|
||||||
|
setOctreeFabrikOnly: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({
|
export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({
|
||||||
@@ -14,6 +22,14 @@ export const useDebugVisualsStore = create<DebugVisualsStore>((set) => ({
|
|||||||
setShowPlayerModel: (showPlayerModel) => set({ showPlayerModel }),
|
setShowPlayerModel: (showPlayerModel) => set({ showPlayerModel }),
|
||||||
showOctree: false,
|
showOctree: false,
|
||||||
setShowOctree: (showOctree) => set({ showOctree }),
|
setShowOctree: (showOctree) => set({ showOctree }),
|
||||||
octreeMaxDepth: 6,
|
octreeMaxDepth: 8,
|
||||||
setOctreeMaxDepth: (octreeMaxDepth) => set({ octreeMaxDepth }),
|
setOctreeMaxDepth: (octreeMaxDepth) => set({ octreeMaxDepth }),
|
||||||
|
octreeMinDepth: 4,
|
||||||
|
setOctreeMinDepth: (octreeMinDepth) => set({ octreeMinDepth }),
|
||||||
|
octreeLeavesOnly: true,
|
||||||
|
setOctreeLeavesOnly: (octreeLeavesOnly) => set({ octreeLeavesOnly }),
|
||||||
|
octreeOpacity: 0.35,
|
||||||
|
setOctreeOpacity: (octreeOpacity) => set({ octreeOpacity }),
|
||||||
|
octreeFabrikOnly: false,
|
||||||
|
setOctreeFabrikOnly: (octreeFabrikOnly) => set({ octreeFabrikOnly }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -272,18 +272,20 @@ function CollisionModelInstance({
|
|||||||
});
|
});
|
||||||
const sceneInstance = useClonedObject(scene);
|
const sceneInstance = useClonedObject(scene);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Strip the door slab from the la fabrik collision octree so the player
|
|
||||||
// can walk through the doorway. The visual model is rendered separately
|
|
||||||
// by MergedStaticMapModel and is unaffected.
|
|
||||||
if (node.name !== "lafabrik") return;
|
if (node.name !== "lafabrik") return;
|
||||||
|
|
||||||
const removed: THREE.Object3D[] = [];
|
const isDoorSlab = (name: string): boolean =>
|
||||||
|
name === "porte" || /^porte[._]\d+$/i.test(name);
|
||||||
|
const isDoorFrameThickenChild = (child: THREE.Object3D): boolean =>
|
||||||
|
child.parent?.name === "Thicken";
|
||||||
|
|
||||||
|
const doorMeshes: THREE.Object3D[] = [];
|
||||||
sceneInstance.traverse((child) => {
|
sceneInstance.traverse((child) => {
|
||||||
if (child.name === "porte") {
|
if (isDoorSlab(child.name) || isDoorFrameThickenChild(child)) {
|
||||||
removed.push(child);
|
doorMeshes.push(child);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
for (const child of removed) {
|
for (const child of doorMeshes) {
|
||||||
child.removeFromParent();
|
child.removeFromParent();
|
||||||
}
|
}
|
||||||
}, [node.name, sceneInstance]);
|
}, [node.name, sceneInstance]);
|
||||||
@@ -326,10 +328,16 @@ function CollisionModelInstance({
|
|||||||
|
|
||||||
function CollisionBox({ box }: { box: OctreeCollisionBox }): React.JSX.Element {
|
function CollisionBox({ box }: { box: OctreeCollisionBox }): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<mesh position={box.center}>
|
<group position={box.center}>
|
||||||
<boxGeometry args={box.size} />
|
<mesh>
|
||||||
<meshBasicMaterial />
|
<boxGeometry args={box.size} />
|
||||||
</mesh>
|
<meshBasicMaterial />
|
||||||
|
</mesh>
|
||||||
|
<mesh rotation={[0, Math.PI, 0]}>
|
||||||
|
<boxGeometry args={box.size} />
|
||||||
|
<meshBasicMaterial />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-14
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "@/data/world/lightingConfig";
|
} from "@/data/world/lightingConfig";
|
||||||
import { LA_FABRIK_INTERIOR_LIGHT_POSITION } from "@/data/world/laFabrikConfig";
|
import { LA_FABRIK_INTERIOR_LIGHT_POSITION } from "@/data/world/laFabrikConfig";
|
||||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||||
|
import { useShadowMapWarmup } from "@/hooks/three/useShadowMapWarmup";
|
||||||
import { LIGHTING_STATE } from "@/world/lightingState";
|
import { LIGHTING_STATE } from "@/world/lightingState";
|
||||||
|
|
||||||
function configureRendererShadows(gl: WebGLRenderer): void {
|
function configureRendererShadows(gl: WebGLRenderer): void {
|
||||||
@@ -51,9 +52,24 @@ function configureSunShadow(sun: DirectionalLight, sunTarget: Object3D): void {
|
|||||||
sun.shadow.camera.updateProjectionMatrix();
|
sun.shadow.camera.updateProjectionMatrix();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function placeSunRelativeToCamera(
|
||||||
|
sun: DirectionalLight,
|
||||||
|
sunTarget: Object3D,
|
||||||
|
cameraPosition: { x: number; z: number },
|
||||||
|
): void {
|
||||||
|
sunTarget.position.set(cameraPosition.x, 0, cameraPosition.z);
|
||||||
|
sun.position.set(
|
||||||
|
cameraPosition.x + LIGHTING_STATE.sunX,
|
||||||
|
LIGHTING_STATE.sunY,
|
||||||
|
cameraPosition.z + LIGHTING_STATE.sunZ,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Lighting(): React.JSX.Element {
|
export function Lighting(): React.JSX.Element {
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
const gl = useThree((state) => state.gl);
|
const gl = useThree((state) => state.gl);
|
||||||
|
const scene = useThree((state) => state.scene);
|
||||||
|
const invalidate = useThree((state) => state.invalidate);
|
||||||
const ambient = useRef<AmbientLight>(null);
|
const ambient = useRef<AmbientLight>(null);
|
||||||
const sun = useRef<DirectionalLight>(null);
|
const sun = useRef<DirectionalLight>(null);
|
||||||
const sunTarget = useRef<Object3D>(null);
|
const sunTarget = useRef<Object3D>(null);
|
||||||
@@ -61,9 +77,16 @@ export function Lighting(): React.JSX.Element {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sun.current || !sunTarget.current) return;
|
if (!sun.current || !sunTarget.current) return;
|
||||||
|
|
||||||
configureSunShadow(sun.current, sunTarget.current);
|
|
||||||
configureRendererShadows(gl);
|
configureRendererShadows(gl);
|
||||||
}, [gl]);
|
configureSunShadow(sun.current, sunTarget.current);
|
||||||
|
// Prime the sun + target onto the camera before the first shadow pass so
|
||||||
|
// the initial shadow frustum already covers the visible scene; without
|
||||||
|
// this, the first frame is rendered with the default (origin-centered)
|
||||||
|
// frustum and shadows can appear absent until the player moves.
|
||||||
|
placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position);
|
||||||
|
}, [camera, gl]);
|
||||||
|
|
||||||
|
useShadowMapWarmup({ light: sun, scene, gl, invalidate });
|
||||||
|
|
||||||
useDebugFolder("Lighting", (folder) => {
|
useDebugFolder("Lighting", (folder) => {
|
||||||
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
|
folder.addColor(LIGHTING_STATE, "ambientColor").name("Ambient Color");
|
||||||
@@ -103,18 +126,14 @@ export function Lighting(): React.JSX.Element {
|
|||||||
ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
|
ambient.current.intensity = LIGHTING_STATE.ambientIntensity;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sun.current && sunTarget.current) {
|
if (!sun.current || !sunTarget.current) return;
|
||||||
sunTarget.current.position.set(camera.position.x, 0, camera.position.z);
|
|
||||||
sunTarget.current.updateMatrixWorld();
|
placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position);
|
||||||
sun.current.position.set(
|
sunTarget.current.updateMatrixWorld();
|
||||||
camera.position.x + LIGHTING_STATE.sunX,
|
sun.current.color.set(LIGHTING_STATE.sunColor);
|
||||||
LIGHTING_STATE.sunY,
|
sun.current.intensity = LIGHTING_STATE.sunIntensity;
|
||||||
camera.position.z + LIGHTING_STATE.sunZ,
|
sun.current.updateMatrixWorld();
|
||||||
);
|
sun.current.shadow.needsUpdate = true;
|
||||||
sun.current.color.set(LIGHTING_STATE.sunColor);
|
|
||||||
sun.current.intensity = LIGHTING_STATE.sunIntensity;
|
|
||||||
sun.current.updateMatrixWorld();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user