add debug labels and some missing shapes

This commit is contained in:
Brian Beck 2025-11-23 21:47:49 -08:00
parent 7557d2c48e
commit 878c798bcd
21 changed files with 385 additions and 147 deletions

View file

@ -10,6 +10,7 @@ import { InspectorControls } from "@/src/components/InspectorControls";
import { SettingsProvider } from "@/src/components/SettingsProvider";
import { ObserverCamera } from "@/src/components/ObserverCamera";
import { AudioProvider } from "@/src/components/AudioContext";
import { DebugElements } from "@/src/components/DebugElements";
// three.js has its own loaders for textures and models, but we need to load other
// stuff too, e.g. missions, terrains, and more. This client is used for those.
@ -35,11 +36,12 @@ function MapInspector() {
<QueryClientProvider client={queryClient}>
<main>
<SettingsProvider>
<Canvas shadows>
<Canvas shadows frameloop="always">
<AudioProvider>
<ObserverControls />
<Mission key={missionName} name={missionName} />
<ObserverCamera />
<DebugElements />
</AudioProvider>
<EffectComposer>
<N8AO intensity={3} aoRadius={3} quality="performance" />

View file

@ -45,3 +45,15 @@ main {
#speedInput {
max-width: 80px;
}
.StaticShapeLabel {
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 11px;
white-space: nowrap;
}
.StatsPanel {
left: auto !important;
right: 0;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -5,6 +5,7 @@ import { ConsoleObject, getPosition, getProperty } from "../mission";
import { audioToUrl } from "../loaders";
import { useAudio } from "./AudioContext";
import { useSettings } from "./SettingsProvider";
import { FloatingLabel } from "./FloatingLabel";
// Global audio buffer cache
const audioBufferCache = new Map<string, AudioBuffer>();
@ -204,12 +205,17 @@ export function AudioEmitter({ object }: { object: ConsoleObject }) {
return debugMode ? (
<mesh position={emitterPosRef.current}>
<sphereGeometry args={[minDistance, 12, 12]} />
<meshStandardMaterial
<meshBasicMaterial
color="#00ff00"
wireframe
opacity={0.2}
transparent
toneMapped={false}
fog={false}
/>
<FloatingLabel color="#00ff00" position={[0, minDistance + 1, 0]}>
{fileName}
</FloatingLabel>
</mesh>
) : null;
}

View file

@ -0,0 +1,12 @@
import { Stats } from "@react-three/drei";
import { useSettings } from "./SettingsProvider";
export function DebugElements() {
const { debugMode } = useSettings();
return debugMode ? (
<>
<Stats className="StatsPanel" />
</>
) : null;
}

View file

@ -0,0 +1,58 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { Object3D } from "three";
import { useDistanceFromCamera } from "./useDistanceFromCamera";
import { useFrame } from "@react-three/fiber";
import { Html } from "@react-three/drei";
const DEFAULT_POSITION = [0, 0, 0] as [x: number, y: number, z: number];
export function FloatingLabel({
children,
color = "white",
position = DEFAULT_POSITION,
}: {
children: ReactNode;
color?: string;
position?: [x: number, y: number, z: number];
}) {
const groupRef = useRef<Object3D>(null);
const distanceRef = useDistanceFromCamera(groupRef);
const [isVisible, setIsVisible] = useState(false);
const labelRef = useRef<HTMLDivElement>(null);
// Initialize opacity when label ref is attached
useEffect(() => {
if (labelRef.current && distanceRef.current != null) {
const opacity = Math.max(0, Math.min(1, 1 - distanceRef.current / 200));
labelRef.current.style.opacity = opacity.toString();
}
}, [isVisible]);
useFrame(() => {
const distance = distanceRef.current;
const shouldBeVisible = distance != null && distance < 200;
// Update visibility state only when crossing threshold
if (isVisible !== shouldBeVisible) {
setIsVisible(shouldBeVisible);
}
// Update opacity directly on DOM element (no re-render)
if (labelRef.current && shouldBeVisible) {
const opacity = Math.max(0, Math.min(1, 1 - distance / 200));
labelRef.current.style.opacity = opacity.toString();
}
});
return (
<group ref={groupRef}>
{isVisible ? (
<Html position={position} center>
<div ref={labelRef} className="StaticShapeLabel" style={{ color }}>
{children}
</div>
</Html>
) : null}
</group>
);
}

View file

@ -1,4 +1,4 @@
import { Suspense } from "react";
import { Suspense, useMemo } from "react";
import { useGLTF, useTexture } from "@react-three/drei";
import { BASE_URL, shapeTextureToUrl, shapeToUrl } from "../loaders";
import { filterGeometryByVertexGroups, getHullBoneIndices } from "../meshUtils";
@ -8,6 +8,9 @@ import {
} from "../shaderMaterials";
import { MeshStandardMaterial } from "three";
import { setupColor } from "../textureUtils";
import { useSettings } from "./SettingsProvider";
import { useShapeInfo } from "./ShapeInfoProvider";
import { FloatingLabel } from "./FloatingLabel";
const FALLBACK_URL = `${BASE_URL}/black.png`;
@ -27,7 +30,7 @@ export function ShapeTexture({
shapeName?: string;
}) {
const url = shapeTextureToUrl(material.name, FALLBACK_URL);
const isOrganic = shapeName && /borg|xorg/i.test(shapeName);
const isOrganic = shapeName && /borg|xorg|porg/i.test(shapeName);
const texture = useTexture(url, (texture) => {
if (!isOrganic) {
@ -36,80 +39,23 @@ export function ShapeTexture({
return setupColor(texture);
});
// Only use alpha-as-roughness material for borg shapes
if (!isOrganic) {
const shaderMaterial = createAlphaAsRoughnessMaterial();
shaderMaterial.map = texture;
return <primitive object={shaderMaterial} attach="material" />;
}
const customMaterial = useMemo(() => {
// Only use alpha-as-roughness material for borg shapes
if (!isOrganic) {
const shaderMaterial = createAlphaAsRoughnessMaterial();
shaderMaterial.map = texture;
return shaderMaterial;
}
// For non-borg shapes, use the original GLTF material with updated texture
const clonedMaterial = material.clone();
clonedMaterial.map = texture;
clonedMaterial.transparent = true;
clonedMaterial.alphaTest = 0.9;
return <primitive object={clonedMaterial} attach="material" />;
}
// For non-borg shapes, use the original GLTF material with updated texture
const clonedMaterial = material.clone();
clonedMaterial.map = texture;
clonedMaterial.transparent = true;
clonedMaterial.alphaTest = 0.9;
return clonedMaterial;
}, [material, texture, isOrganic]);
export function ShapeModel({ shapeName }: { shapeName: string }) {
const { nodes } = useStaticShape(shapeName);
let hullBoneIndices = new Set<number>();
const skeletonsFound = Object.values(nodes).filter(
(node: any) => node.skeleton
);
if (skeletonsFound.length > 0) {
const skeleton = (skeletonsFound[0] as any).skeleton;
hullBoneIndices = getHullBoneIndices(skeleton);
}
return (
<>
{Object.entries(nodes)
.filter(
([name, node]: [string, any]) =>
node.material &&
node.material.name !== "Unassigned" &&
!node.name.match(/^Hulk/i)
)
.map(([name, node]: [string, any]) => {
const geometry = filterGeometryByVertexGroups(
node.geometry,
hullBoneIndices
);
return (
<mesh key={node.id} geometry={geometry} castShadow receiveShadow>
{node.material ? (
<Suspense
fallback={
// Allow the mesh to render while the texture is still loading;
// show a wireframe placeholder.
<meshStandardMaterial color="gray" wireframe />
}
>
{Array.isArray(node.material) ? (
node.material.map((mat, index) => (
<ShapeTexture
key={index}
material={mat as MeshStandardMaterial}
shapeName={shapeName}
/>
))
) : (
<ShapeTexture
material={node.material as MeshStandardMaterial}
shapeName={shapeName}
/>
)}
</Suspense>
) : null}
</mesh>
);
})}
</>
);
return <primitive object={customMaterial} attach="material" />;
}
export function ShapePlaceholder({ color }: { color: string }) {
@ -120,3 +66,74 @@ export function ShapePlaceholder({ color }: { color: string }) {
</mesh>
);
}
export type StaticShapeType = "StaticShape" | "TSStatic" | "Item" | "Turret";
export function ShapeModel() {
const { shapeName } = useShapeInfo();
const { debugMode } = useSettings();
const { nodes } = useStaticShape(shapeName);
const hullBoneIndices = useMemo(() => {
const skeletonsFound = Object.values(nodes).filter(
(node: any) => node.skeleton
);
if (skeletonsFound.length > 0) {
const skeleton = (skeletonsFound[0] as any).skeleton;
return getHullBoneIndices(skeleton);
}
return new Set<number>();
}, [nodes]);
const processedNodes = useMemo(() => {
return Object.entries(nodes)
.filter(
([name, node]: [string, any]) =>
node.material &&
node.material.name !== "Unassigned" &&
!node.name.match(/^Hulk/i)
)
.map(([name, node]: [string, any]) => {
const geometry = filterGeometryByVertexGroups(
node.geometry,
hullBoneIndices
);
return { node, geometry };
});
}, [nodes, hullBoneIndices]);
return (
<>
{processedNodes.map(({ node, geometry }) => (
<mesh key={node.id} geometry={geometry} castShadow receiveShadow>
{node.material ? (
<Suspense
fallback={
// Allow the mesh to render while the texture is still loading;
// show a wireframe placeholder.
<meshStandardMaterial color="gray" wireframe />
}
>
{Array.isArray(node.material) ? (
node.material.map((mat, index) => (
<ShapeTexture
key={index}
material={mat as MeshStandardMaterial}
shapeName={shapeName}
/>
))
) : (
<ShapeTexture
material={node.material as MeshStandardMaterial}
shapeName={shapeName}
/>
)}
</Suspense>
) : null}
</mesh>
))}
{debugMode ? <FloatingLabel>{shapeName}</FloatingLabel> : null}
</>
);
}

View file

@ -8,6 +8,7 @@ import {
getScale,
} from "../mission";
import { ShapeModel, ShapePlaceholder } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
const dataBlockToShapeName = {
AmmoPack: "pack_upgrade_ammo.dts",
@ -67,20 +68,22 @@ export function Item({ object }: { object: ConsoleObject }) {
}
return (
<group
quaternion={q}
position={[x - 1024, y, z - 1024]}
scale={[scaleX, scaleY, scaleZ]}
>
{shapeName ? (
<ErrorBoundary fallback={<ShapePlaceholder color="red" />}>
<Suspense fallback={<ShapePlaceholder color="pink" />}>
<ShapeModel shapeName={shapeName} />
</Suspense>
</ErrorBoundary>
) : (
<ShapePlaceholder color="orange" />
)}
</group>
<ShapeInfoProvider shapeName={shapeName} type="Item">
<group
quaternion={q}
position={[x - 1024, y, z - 1024]}
scale={[scaleX, scaleY, scaleZ]}
>
{shapeName ? (
<ErrorBoundary fallback={<ShapePlaceholder color="red" />}>
<Suspense fallback={<ShapePlaceholder color="pink" />}>
<ShapeModel />
</Suspense>
</ErrorBoundary>
) : (
<ShapePlaceholder color="orange" />
)}
</group>
</ShapeInfoProvider>
);
}

View file

@ -4,6 +4,7 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
@ -14,6 +15,7 @@ type PersistedSettings = {
speedMultiplier?: number;
fov?: number;
audioEnabled?: boolean;
debugMode?: boolean;
};
export function useSettings() {
@ -51,6 +53,12 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
} catch (err) {
// Ignore.
}
if (savedSettings.debugMode != null) {
setDebugMode(savedSettings.debugMode);
}
if (savedSettings.audioEnabled != null) {
setAudioEnabled(savedSettings.audioEnabled);
}
if (savedSettings.fogEnabled != null) {
setFogEnabled(savedSettings.fogEnabled);
}
@ -62,19 +70,37 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
}
}, []);
// Persist settings to localStoarge.
// Persist settings to localStorage with debouncing to avoid excessive writes
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const settingsToSave: PersistedSettings = {
fogEnabled,
speedMultiplier,
fov,
};
try {
localStorage.setItem("settings", JSON.stringify(settingsToSave));
} catch (err) {
// Probably forbidden by browser settings.
// Clear any pending save
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
}, [fogEnabled, speedMultiplier, fov]);
// Debounce localStorage writes (wait 300ms after last change)
saveTimerRef.current = setTimeout(() => {
const settingsToSave: PersistedSettings = {
fogEnabled,
speedMultiplier,
fov,
audioEnabled,
debugMode,
};
try {
localStorage.setItem("settings", JSON.stringify(settingsToSave));
} catch (err) {
// Probably forbidden by browser settings.
}
}, 500);
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
};
}, [fogEnabled, speedMultiplier, fov, audioEnabled, debugMode]);
return (
<SettingsContext.Provider value={value}>

View file

@ -0,0 +1,27 @@
import { createContext, ReactNode, useContext, useMemo } from "react";
export type StaticShapeType = "TSStatic" | "StaticShape" | "Item" | "Turret";
const ShapeInfoContext = createContext(null);
export function useShapeInfo() {
return useContext(ShapeInfoContext);
}
export function ShapeInfoProvider({
children,
shapeName,
type,
}: {
children: ReactNode;
shapeName: string;
type: StaticShapeType;
}) {
const context = useMemo(() => ({ shapeName, type }), [shapeName, type]);
return (
<ShapeInfoContext.Provider value={context}>
{children}
</ShapeInfoContext.Provider>
);
}

View file

@ -8,6 +8,7 @@ import {
getScale,
} from "../mission";
import { ShapeModel, ShapePlaceholder } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
const dataBlockToShapeName = {
Banner_Honor: "banner_honor.dts",
@ -57,20 +58,22 @@ export function StaticShape({ object }: { object: ConsoleObject }) {
}
return (
<group
quaternion={q}
position={[x - 1024, y, z - 1024]}
scale={[scaleX, scaleY, scaleZ]}
>
{shapeName ? (
<ErrorBoundary fallback={<ShapePlaceholder color="red" />}>
<Suspense fallback={<ShapePlaceholder color="yellow" />}>
<ShapeModel shapeName={shapeName} />
</Suspense>
</ErrorBoundary>
) : (
<ShapePlaceholder color="orange" />
)}
</group>
<ShapeInfoProvider shapeName={shapeName} type="StaticShape">
<group
quaternion={q}
position={[x - 1024, y, z - 1024]}
scale={[scaleX, scaleY, scaleZ]}
>
{shapeName ? (
<ErrorBoundary fallback={<ShapePlaceholder color="red" />}>
<Suspense fallback={<ShapePlaceholder color="yellow" />}>
<ShapeModel />
</Suspense>
</ErrorBoundary>
) : (
<ShapePlaceholder color="orange" />
)}
</group>
</ShapeInfoProvider>
);
}

View file

@ -8,6 +8,7 @@ import {
getScale,
} from "../mission";
import { ShapeModel, ShapePlaceholder } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
export function TSStatic({ object }: { object: ConsoleObject }) {
const shapeName = getProperty(object, "shapeName").value;
@ -16,17 +17,23 @@ export function TSStatic({ object }: { object: ConsoleObject }) {
const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object, true), [object]);
if (!shapeName) {
console.error("<TSStatic> missing shapeName for object", object);
}
return (
<group
quaternion={q}
position={[x - 1024, y, z - 1024]}
scale={[scaleX, scaleY, scaleZ]}
>
<ErrorBoundary fallback={<ShapePlaceholder color="red" />}>
<Suspense fallback={<ShapePlaceholder color="yellow" />}>
<ShapeModel shapeName={shapeName} />
</Suspense>
</ErrorBoundary>
</group>
<ShapeInfoProvider shapeName={shapeName} type="TSStatic">
<group
quaternion={q}
position={[x - 1024, y, z - 1024]}
scale={[scaleX, scaleY, scaleZ]}
>
<ErrorBoundary fallback={<ShapePlaceholder color="red" />}>
<Suspense fallback={<ShapePlaceholder color="yellow" />}>
<ShapeModel />
</Suspense>
</ErrorBoundary>
</group>
</ShapeInfoProvider>
);
}

View file

@ -8,6 +8,7 @@ import {
getScale,
} from "../mission";
import { ShapeModel, ShapePlaceholder } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
const dataBlockToShapeName = {
AABarrelLarge: "turret_aa_large.dts",
@ -17,6 +18,7 @@ const dataBlockToShapeName = {
PlasmaBarrelLarge: "turret_fusion_large.dts",
SentryTurret: "turret_sentry.dts",
TurretBaseLarge: "turret_base_large.dts",
SentryTurretBarrel: "turret_muzzlepoint.dts",
};
let _caseInsensitiveLookup: Record<string, string>;
@ -46,33 +48,42 @@ export function Turret({ object }: { object: ConsoleObject }) {
if (!shapeName) {
console.error(`<Turret> missing shape for dataBlock: ${dataBlock}`);
}
if (!barrelShapeName) {
console.error(
`<Turret> missing shape for initialBarrel dataBlock: ${initialBarrel}`
);
}
return (
<group
quaternion={q}
position={[x - 1024, y, z - 1024]}
scale={[-scaleX, scaleY, scaleZ]}
>
{shapeName ? (
<ErrorBoundary fallback={<ShapePlaceholder color="red" />}>
<Suspense fallback={<ShapePlaceholder color="yellow" />}>
<ShapeModel shapeName={shapeName} />
</Suspense>
</ErrorBoundary>
) : (
<ShapePlaceholder color="orange" />
)}
<group position={[0, 1.5, 0]}>
{barrelShapeName ? (
<ShapeInfoProvider shapeName={shapeName} type="Turret">
<group
quaternion={q}
position={[x - 1024, y, z - 1024]}
scale={[-scaleX, scaleY, scaleZ]}
>
{shapeName ? (
<ErrorBoundary fallback={<ShapePlaceholder color="red" />}>
<Suspense fallback={<ShapePlaceholder color="yellow" />}>
<ShapeModel shapeName={barrelShapeName} />
<ShapeModel />
</Suspense>
</ErrorBoundary>
) : (
<ShapePlaceholder color="orange" />
)}
<ShapeInfoProvider shapeName={barrelShapeName} type="Turret">
<group position={[0, 1.5, 0]}>
{barrelShapeName ? (
<ErrorBoundary fallback={<ShapePlaceholder color="red" />}>
<Suspense fallback={<ShapePlaceholder color="yellow" />}>
<ShapeModel />
</Suspense>
</ErrorBoundary>
) : (
<ShapePlaceholder color="orange" />
)}
</group>
</ShapeInfoProvider>
</group>
</group>
</ShapeInfoProvider>
);
}

View file

@ -0,0 +1,22 @@
import { useFrame, useThree } from "@react-three/fiber";
import { RefObject, useRef } from "react";
import { Object3D } from "three";
import { useWorldPosition } from "./useWorldPosition";
export function useDistanceFromCamera<T extends Object3D>(
ref: RefObject<T>
): RefObject<number> {
const { camera } = useThree();
const distanceRef = useRef<number>(null);
const worldPosRef = useWorldPosition(ref);
useFrame(() => {
if (!worldPosRef.current) {
distanceRef.current = null;
} else {
distanceRef.current = camera.position.distanceTo(worldPosRef.current);
}
});
return distanceRef;
}

View file

@ -0,0 +1,18 @@
import { useFrame } from "@react-three/fiber";
import { useRef, RefObject } from "react";
import { Object3D, Vector3 } from "three";
export function useWorldPosition<T extends Object3D>(
ref: RefObject<T>
): RefObject<Vector3 | null> {
const worldPositionRef = useRef<Vector3 | null>(null);
useFrame(() => {
if (ref.current) {
worldPositionRef.current ??= new Vector3();
ref.current.getWorldPosition(worldPositionRef.current);
}
});
return worldPositionRef;
}

View file

@ -51,6 +51,7 @@ export function textureFrameToUrl(fileName: string) {
}
export function shapeTextureToUrl(name: string, fallbackUrl?: string) {
name = name.replace(/^skins\\/, "");
name = name.replace(/\.\d+$/, "");
return getUrlForPath(`textures/skins/${name}.png`, fallbackUrl);
}

View file

@ -9,7 +9,18 @@ export function getSource(resourcePath: string) {
}
}
const _resourcePathCache = new Map();
export function getActualResourcePath(resourcePath: string) {
if (_resourcePathCache.has(resourcePath)) {
return _resourcePathCache.get(resourcePath);
}
const actualResourcePath = getActualResourcePathUncached(resourcePath);
_resourcePathCache.set(resourcePath, actualResourcePath);
return actualResourcePath;
}
export function getActualResourcePathUncached(resourcePath: string) {
if (manifest[resourcePath]) {
return resourcePath;
}
@ -57,8 +68,10 @@ export function getActualResourcePath(resourcePath: string) {
return resourcePath;
}
const _cachedResourceList = Object.keys(manifest).sort();
export function getResourceList() {
return Object.keys(manifest).sort();
return _cachedResourceList;
}
export function getFilePath(resourcePath: string) {