Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39b996eb31 | |||
| 134c0aecb7 | |||
| b144dc1c18 | |||
| 69c720b86b | |||
| 1b57a25e5f | |||
| f6db7d74e2 | |||
| a1798aecb3 | |||
| 3b07f40f2d | |||
| 27416143e3 | |||
| a2a491bd5c | |||
| da7d66e1fd | |||
| 5faf4b4197 | |||
| bee0c7f223 | |||
| 216d29ae59 | |||
| e13cf1e4c7 | |||
| d20bdc4934 |
@@ -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.
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;
|
||||||
|
|
||||||
|
const isLeaf = node.subTrees.length === 0;
|
||||||
|
const passesDepth = depth >= options.minDepth;
|
||||||
|
const passesLeafFilter = !options.leavesOnly || isLeaf;
|
||||||
|
const hasTriangles = node.triangles.length > 0;
|
||||||
|
const passesFabrikFilter =
|
||||||
|
!options.fabrikOnly || boxIntersectsFabrik(node.box);
|
||||||
|
|
||||||
|
if (passesDepth && passesLeafFilter && hasTriangles && passesFabrikFilter) {
|
||||||
acc.push({
|
acc.push({
|
||||||
box: node.box,
|
box: node.box,
|
||||||
depth,
|
depth,
|
||||||
triangleCount: node.triangles.length,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+23
-20
@@ -942,11 +942,11 @@ canvas {
|
|||||||
.scene-loading-overlay__logo {
|
.scene-loading-overlay__logo {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
display: block;
|
||||||
width: clamp(180px, 28vw, 320px);
|
width: clamp(180px, 28vw, 320px);
|
||||||
max-height: min(38vh, 320px);
|
max-height: min(38vh, 320px);
|
||||||
border-radius: 16px;
|
height: auto;
|
||||||
object-fit: cover;
|
object-fit: contain;
|
||||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.28);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-loading-overlay__footer {
|
.scene-loading-overlay__footer {
|
||||||
@@ -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}>
|
||||||
|
<mesh>
|
||||||
<boxGeometry args={box.size} />
|
<boxGeometry args={box.size} />
|
||||||
<meshBasicMaterial />
|
<meshBasicMaterial />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
<mesh rotation={[0, Math.PI, 0]}>
|
||||||
|
<boxGeometry args={box.size} />
|
||||||
|
<meshBasicMaterial />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-10
@@ -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);
|
|
||||||
|
placeSunRelativeToCamera(sun.current, sunTarget.current, camera.position);
|
||||||
sunTarget.current.updateMatrixWorld();
|
sunTarget.current.updateMatrixWorld();
|
||||||
sun.current.position.set(
|
|
||||||
camera.position.x + LIGHTING_STATE.sunX,
|
|
||||||
LIGHTING_STATE.sunY,
|
|
||||||
camera.position.z + LIGHTING_STATE.sunZ,
|
|
||||||
);
|
|
||||||
sun.current.color.set(LIGHTING_STATE.sunColor);
|
sun.current.color.set(LIGHTING_STATE.sunColor);
|
||||||
sun.current.intensity = LIGHTING_STATE.sunIntensity;
|
sun.current.intensity = LIGHTING_STATE.sunIntensity;
|
||||||
sun.current.updateMatrixWorld();
|
sun.current.updateMatrixWorld();
|
||||||
}
|
sun.current.shadow.needsUpdate = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user