migrate to react-three-fiber

This commit is contained in:
Brian Beck 2025-11-13 22:55:58 -08:00
parent c20ca94953
commit 76e9f68e63
18 changed files with 1367 additions and 752 deletions

50
app/InspectorControls.tsx Normal file
View file

@ -0,0 +1,50 @@
import { getResourceList } from "@/src/manifest";
const excludeMissions = new Set([
"SkiFree",
"SkiFree_Daily",
"SkiFree_Randomizer",
]);
const missions = getResourceList()
.map((resourcePath) => resourcePath.match(/^missions\/(.+)\.mis$/))
.filter(Boolean)
.map((match) => match[1])
.filter((name) => !excludeMissions.has(name));
export function InspectorControls({
missionName,
onChangeMission,
fogEnabled,
onChangeFogEnabled,
}: {
missionName: string;
onChangeMission: (name: string) => void;
fogEnabled: boolean;
onChangeFogEnabled: (enabled: boolean) => void;
}) {
return (
<div id="controls">
<select
id="missionList"
value={missionName}
onChange={(event) => onChangeMission(event.target.value)}
>
{missions.map((missionName) => (
<option key={missionName}>{missionName}</option>
))}
</select>
<div className="CheckboxField">
<input
id="fogInput"
type="checkbox"
checked={fogEnabled}
onChange={(event) => {
onChangeFogEnabled(event.target.checked);
}}
/>
<label htmlFor="fogInput">Fog?</label>
</div>
</div>
);
}

View file

@ -0,0 +1,91 @@
import { useGLTF, useTexture } from "@react-three/drei";
import { BASE_URL, interiorTextureToUrl, interiorToUrl } from "@/src/loaders";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "@/src/mission";
import { Suspense, useMemo } from "react";
import { Material, Mesh } from "three";
import { setupColor } from "@/src/textureUtils";
const FALLBACK_URL = `${BASE_URL}/black.png`;
function useInterior(interiorFile: string) {
const url = interiorToUrl(interiorFile);
return useGLTF(url);
}
function InteriorTexture({ material }: { material: Material }) {
let url = FALLBACK_URL;
try {
url = interiorTextureToUrl(material.name);
} catch (err) {
console.error(err);
}
const texture = useTexture(url, (texture) => setupColor(texture));
return <meshStandardMaterial map={texture} />;
}
function InteriorMesh({ node }: { node: Mesh }) {
return (
<mesh geometry={node.geometry} castShadow receiveShadow>
{node.material ? (
<Suspense
fallback={
// Allow the mesh to render while the texture is still loading;
// show a wireframe placeholder.
<meshStandardMaterial color="yellow" wireframe />
}
>
<InteriorTexture material={node.material} />
</Suspense>
) : null}
</mesh>
);
}
export function InteriorModel({ interiorFile }: { interiorFile: string }) {
const { nodes } = useInterior(interiorFile);
return (
<>
{Object.entries(nodes)
// .filter(
// ([name, node]: [string, any]) => true
// // !node.material || !node.material.name.match(/\.\d+$/)
// )
.map(([name, node]: [string, any]) => (
<InteriorMesh key={name} node={node} />
))}
</>
);
}
function InteriorPlaceholder() {
return (
<mesh>
<boxGeometry args={[10, 10, 10]} />
<meshStandardMaterial color="orange" wireframe />
</mesh>
);
}
export function InteriorInstance({ object }: { object: ConsoleObject }) {
const interiorFile = getProperty(object, "interiorFile").value;
const [z, y, x] = useMemo(() => getPosition(object), [object]);
const [scaleX, scaleY, scaleZ] = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object, true), [object]);
return (
<group quaternion={q} position={[x, y, z]} scale={[scaleX, scaleY, scaleZ]}>
<Suspense fallback={<InteriorPlaceholder />}>
<InteriorModel interiorFile={interiorFile} />
</Suspense>
</group>
);
}

31
app/Mission.tsx Normal file
View file

@ -0,0 +1,31 @@
import { loadMission } from "@/src/loaders";
import { useQuery } from "@tanstack/react-query";
import { renderObject } from "./renderObject";
function useMission(name: string) {
return useQuery({
queryKey: ["mission", name],
queryFn: () => loadMission(name),
});
}
const DEFAULT_LIGHT_ARGS = [
"rgba(209, 237, 255, 1)",
"rgba(186, 200, 181, 1)",
2,
] as const;
export function Mission({ name }: { name: string }) {
const { data: mission } = useMission(name);
if (!mission) {
return null;
}
return (
<>
<hemisphereLight args={DEFAULT_LIGHT_ARGS} />
{mission.objects.map((object, i) => renderObject(object, i))}
</>
);
}

95
app/ObserverControls.tsx Normal file
View file

@ -0,0 +1,95 @@
import {
KeyboardControls,
KeyboardControlsEntry,
Point,
PointerLockControls,
} from "@react-three/drei";
import { useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useKeyboardControls } from "@react-three/drei";
import * as THREE from "three";
enum Controls {
forward = "forward",
backward = "backward",
left = "left",
right = "right",
up = "up",
down = "down",
}
const BASE_SPEED = 100; // units per second
function CameraMovement() {
const [subscribe, getKeys] = useKeyboardControls<Controls>();
const { camera } = useThree();
// Scratch vectors to avoid allocations each frame
const forwardVec = useRef(new THREE.Vector3());
const sideVec = useRef(new THREE.Vector3());
const moveVec = useRef(new THREE.Vector3());
useFrame((_, delta) => {
const { forward, backward, left, right, up, down } = getKeys();
if (!forward && !backward && !left && !right && !up && !down) {
return;
}
const speed = BASE_SPEED;
// Forward/backward: take complete camera angle into account (including Y)
camera.getWorldDirection(forwardVec.current);
forwardVec.current.normalize();
// Left/right: move along XZ plane
sideVec.current.crossVectors(camera.up, forwardVec.current).normalize();
moveVec.current.set(0, 0, 0);
if (forward) {
moveVec.current.add(forwardVec.current);
}
if (backward) {
moveVec.current.sub(forwardVec.current);
}
if (left) {
moveVec.current.add(sideVec.current);
}
if (right) {
moveVec.current.sub(sideVec.current);
}
if (up) {
moveVec.current.y += 1;
}
if (down) {
moveVec.current.y -= 1;
}
if (moveVec.current.lengthSq() > 0) {
moveVec.current.normalize().multiplyScalar(speed * delta);
camera.position.add(moveVec.current);
}
});
return null;
}
const KEYBOARD_CONTROLS = [
{ name: Controls.forward, keys: ["KeyW"] },
{ name: Controls.backward, keys: ["KeyS"] },
{ name: Controls.left, keys: ["KeyA"] },
{ name: Controls.right, keys: ["KeyD"] },
{ name: Controls.up, keys: ["Space"] },
{ name: Controls.down, keys: ["ShiftLeft", "ShiftRight"] },
];
export function ObserverControls() {
return (
<KeyboardControls map={KEYBOARD_CONTROLS}>
<CameraMovement />
<PointerLockControls makeDefault />
</KeyboardControls>
);
}

23
app/SettingsProvider.tsx Normal file
View file

@ -0,0 +1,23 @@
import React, { useContext, useMemo } from "react";
const SettingsContext = React.createContext(null);
export function useSettings() {
return useContext(SettingsContext);
}
export function SettingsProvider({
children,
fogEnabled,
}: {
children: React.ReactNode;
fogEnabled: boolean;
}) {
const value = useMemo(() => ({ fogEnabled }), [fogEnabled]);
return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
}

6
app/SimGroup.tsx Normal file
View file

@ -0,0 +1,6 @@
import { ConsoleObject } from "@/src/mission";
import { renderObject } from "./renderObject";
export function SimGroup({ object }: { object: ConsoleObject }) {
return object.children.map((child, i) => renderObject(child, i));
}

View file

@ -0,0 +1,94 @@
import { ConsoleObject, getProperty } from "@/src/mission";
import { useSettings } from "./SettingsProvider";
import { Suspense, useMemo } from "react";
import { BASE_URL, getUrlForPath, loadDetailMapList } from "@/src/loaders";
import { useQuery } from "@tanstack/react-query";
import { useCubeTexture } from "@react-three/drei";
import { Color } from "three";
const FALLBACK_URL = `${BASE_URL}/black.png`;
function useDetailMapList(name: string) {
return useQuery({
queryKey: ["detailMapList", name],
queryFn: () => loadDetailMapList(name),
});
}
export function SkyBox({ materialList }: { materialList: string }) {
const { data: detailMapList } = useDetailMapList(materialList);
const skyBoxFiles = useMemo(
() =>
detailMapList
? [
getUrlForPath(detailMapList[1], FALLBACK_URL), // +x
getUrlForPath(detailMapList[3], FALLBACK_URL), // -x
getUrlForPath(detailMapList[4], FALLBACK_URL), // +y
getUrlForPath(detailMapList[5], FALLBACK_URL), // -y
getUrlForPath(detailMapList[0], FALLBACK_URL), // +z
getUrlForPath(detailMapList[2], FALLBACK_URL), // -z
]
: [
FALLBACK_URL,
FALLBACK_URL,
FALLBACK_URL,
FALLBACK_URL,
FALLBACK_URL,
FALLBACK_URL,
],
[detailMapList]
);
const skyBox = useCubeTexture(skyBoxFiles, { path: "" });
return <primitive attach="background" object={skyBox} />;
}
export function Sky({ object }: { object: ConsoleObject }) {
const { fogEnabled } = useSettings();
// Skybox textures.
const materialList = getProperty(object, "materialList")?.value;
// Fog parameters.
// TODO: There can be multiple fog volumes/layers. Render simple fog for now.
const fogDistance = useMemo(() => {
const distainceString = getProperty(object, "fogDistance")?.value;
if (distainceString) {
return parseFloat(distainceString);
}
}, [object]);
const fogColor = useMemo(() => {
const colorString = getProperty(object, "fogColor")?.value;
if (colorString) {
// `colorString` might specify an alpha value, but three.js doesn't
// support opacity on fog or scene backgrounds, so ignore it.
const [r, g, b] = colorString.split(" ").map((s) => parseFloat(s));
return new Color().setRGB(r, g, b);
}
}, [object]);
const backgroundColor = fogColor ? (
<color attach="background" args={[fogColor]} />
) : null;
return (
<>
{materialList ? (
// If there's a skybox, its textures will need to load. Render just the
// fog color as the background in the meantime.
<Suspense fallback={backgroundColor}>
<SkyBox materialList={materialList} />
</Suspense>
) : (
// If there's no skybox, just render the fog color as the background.
backgroundColor
)}
{fogEnabled && fogDistance && fogColor ? (
<fog attach="fog" color={fogColor} near={0} far={fogDistance * 2} />
) : null}
</>
);
}

View file

@ -0,0 +1,220 @@
import { uint16ToFloat32 } from "@/src/arrayUtils";
import { loadTerrain, terrainTextureToUrl } from "@/src/loaders";
import {
ConsoleObject,
getPosition,
getProperty,
getRotation,
getScale,
} from "@/src/mission";
import { useQuery } from "@tanstack/react-query";
import { Suspense, useCallback, useMemo } from "react";
import { useTexture } from "@react-three/drei";
import {
DataTexture,
RedFormat,
FloatType,
NoColorSpace,
NearestFilter,
ClampToEdgeWrapping,
UnsignedByteType,
PlaneGeometry,
} from "three";
import {
setupColor,
setupMask,
updateTerrainTextureShader,
} from "@/src/textureUtils";
function useTerrain(terrainFile: string) {
return useQuery({
queryKey: ["terrain", terrainFile],
queryFn: () => loadTerrain(terrainFile),
});
}
function BlendedTerrainTextures({
displacementMap,
visibilityMask,
textureNames,
alphaMaps,
}: {
displacementMap: DataTexture;
visibilityMask: DataTexture;
textureNames: string[];
alphaMaps: Uint8Array[];
}) {
const baseTextures = useTexture(
textureNames.map((name) => terrainTextureToUrl(name)),
(textures) => {
textures.forEach((tex) => setupColor(tex));
}
);
const alphaTextures = useMemo(
() => alphaMaps.map((data) => setupMask(data)),
[alphaMaps]
);
const onBeforeCompile = useCallback(
(shader) => {
updateTerrainTextureShader({
shader,
baseTextures,
alphaTextures,
visibilityMask,
});
},
[baseTextures, alphaTextures, visibilityMask]
);
return (
<meshStandardMaterial
displacementMap={displacementMap}
map={displacementMap}
displacementScale={2048}
depthWrite
onBeforeCompile={onBeforeCompile}
/>
);
}
function TerrainMaterial({
heightMap,
textureNames,
alphaMaps,
emptySquares,
}: {
heightMap: Uint16Array;
emptySquares: number[];
textureNames: string[];
alphaMaps: Uint8Array[];
}) {
const displacementMap = useMemo(() => {
const f32HeightMap = uint16ToFloat32(heightMap);
const displacementMap = new DataTexture(
f32HeightMap,
256,
256,
RedFormat,
FloatType
);
displacementMap.colorSpace = NoColorSpace;
displacementMap.generateMipmaps = false;
displacementMap.needsUpdate = true;
return displacementMap;
}, [heightMap]);
const visibilityMask: DataTexture | null = useMemo(() => {
if (!emptySquares.length) {
return null;
}
const terrainSize = 256;
// Create a mask texture (1 = visible, 0 = invisible)
const maskData = new Uint8Array(terrainSize * terrainSize);
maskData.fill(255); // Start with everything visible
for (const squareId of emptySquares) {
// The squareId encodes position and count:
// Bits 0-7: X position (starting position)
// Bits 8-15: Y position
// Bits 16+: Count (number of consecutive horizontal squares)
const x = squareId & 0xff;
const y = (squareId >> 8) & 0xff;
const count = squareId >> 16;
for (let i = 0; i < count; i++) {
const px = x + i;
const py = y;
const index = py * terrainSize + px;
if (index >= 0 && index < maskData.length) {
maskData[index] = 0;
}
}
}
const visibilityMask = new DataTexture(
maskData,
terrainSize,
terrainSize,
RedFormat,
UnsignedByteType
);
visibilityMask.colorSpace = NoColorSpace;
visibilityMask.wrapS = visibilityMask.wrapT = ClampToEdgeWrapping;
visibilityMask.magFilter = NearestFilter;
visibilityMask.minFilter = NearestFilter;
visibilityMask.needsUpdate = true;
return visibilityMask;
}, [emptySquares]);
return (
<Suspense
fallback={
// Render a wireframe while the terrain textures load.
<meshStandardMaterial
color="rgb(0, 109, 56)"
displacementMap={displacementMap}
displacementScale={2048}
wireframe
/>
}
>
<BlendedTerrainTextures
displacementMap={displacementMap}
visibilityMask={visibilityMask}
textureNames={textureNames}
alphaMaps={alphaMaps}
/>
</Suspense>
);
}
export function TerrainBlock({ object }: { object: ConsoleObject }) {
const terrainFile: string = getProperty(object, "terrainFile").value;
const emptySquares: number[] = useMemo(() => {
const emptySquaresString: string | undefined = getProperty(
object,
"emptySquares"
)?.value;
return emptySquaresString
? emptySquaresString.split(" ").map((s) => parseInt(s, 10))
: [];
}, [object]);
const position = useMemo(() => getPosition(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const q = useMemo(() => getRotation(object), [object]);
const planeGeometry = useMemo(() => {
const geometry = new PlaneGeometry(2048, 2048, 256, 256);
geometry.rotateX(-Math.PI / 2);
geometry.rotateY(-Math.PI / 2);
return geometry;
}, []);
const { data: terrain } = useTerrain(terrainFile);
return (
<mesh
quaternion={q}
position={position}
scale={scale}
geometry={planeGeometry}
>
{terrain ? (
<TerrainMaterial
heightMap={terrain.heightMap}
emptySquares={emptySquares}
textureNames={terrain.textureNames}
alphaMaps={terrain.alphaMaps}
/>
) : null}
</mesh>
);
}

View file

@ -0,0 +1,10 @@
import { ConsoleObject } from "@/src/mission";
export function WaterBlock({ object }: { object: ConsoleObject }) {
return (
<mesh>
<boxGeometry />
<meshStandardMaterial color="blue" transparent opacity={0.5} />
</mesh>
);
}

View file

@ -1,738 +1,42 @@
"use client";
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import {
getActualResourcePath,
getResourceList,
getSource,
} from "@/src/manifest";
import { parseTerrainBuffer } from "@/src/terrain";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import { getTerrainFile, iterObjects, parseMissionScript } from "@/src/mission";
import { useState } from "react";
import { Canvas } from "@react-three/fiber";
import { Mission } from "./Mission";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ObserverControls } from "./ObserverControls";
import { InspectorControls } from "./InspectorControls";
import { SettingsProvider } from "./SettingsProvider";
import { PerspectiveCamera } from "@react-three/drei";
const BASE_URL = "/t2-mapper";
const RESOURCE_ROOT_URL = `${BASE_URL}/base/`;
function getUrlForPath(resourcePath: string, fallbackUrl?: string) {
resourcePath = getActualResourcePath(resourcePath);
let sourcePath: string;
try {
sourcePath = getSource(resourcePath);
} catch (err) {
if (fallbackUrl) {
return fallbackUrl;
} else {
throw err;
}
}
if (!sourcePath) {
return `${RESOURCE_ROOT_URL}${resourcePath}`;
} else {
return `${RESOURCE_ROOT_URL}@vl2/${sourcePath}/${resourcePath}`;
}
}
function interiorToUrl(name: string) {
const difUrl = getUrlForPath(`interiors/${name}`);
return difUrl.replace(/\.dif$/i, ".gltf");
}
function terrainTextureToUrl(name: string) {
name = name.replace(/^terrain\./, "");
return getUrlForPath(`textures/terrain/${name}.png`, `${BASE_URL}/black.png`);
}
function interiorTextureToUrl(name: string) {
name = name.replace(/\.\d+$/, "");
return getUrlForPath(`textures/${name}.png`);
}
function textureToUrl(name: string) {
try {
return getUrlForPath(`textures/${name}.png`);
} catch (err) {
return `${BASE_URL}/black.png`;
}
}
async function loadDetailMapList(name: string) {
const url = getUrlForPath(`textures/${name}`);
const res = await fetch(url);
const text = await res.text();
return text
.split(/(?:\r\n|\n|\r)/)
.map((line) => `textures/${line.trim().replace(/\.png$/i, "")}.png`);
}
function uint16ToFloat32(src: Uint16Array) {
const out = new Float32Array(src.length);
for (let i = 0; i < src.length; i++) {
out[i] = src[i] / 65535;
}
return out;
}
async function loadMission(name: string) {
const res = await fetch(getUrlForPath(`missions/${name}.mis`));
const missionScript = await res.text();
return parseMissionScript(missionScript);
}
async function loadTerrain(fileName: string) {
const res = await fetch(getUrlForPath(`terrains/${fileName}`));
const terrainBuffer = await res.arrayBuffer();
return parseTerrainBuffer(terrainBuffer);
}
function resizeRendererToDisplaySize(renderer) {
const canvas = renderer.domElement;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const needResize = canvas.width !== width || canvas.height !== height;
if (needResize) {
renderer.setSize(width, height, false);
}
return needResize;
}
const excludeMissions = new Set([
"SkiFree",
"SkiFree_Daily",
"SkiFree_Randomizer",
]);
const missions = getResourceList()
.map((resourcePath) => resourcePath.match(/^missions\/(.+)\.mis$/))
.filter(Boolean)
.map((match) => match[1])
.filter((name) => !excludeMissions.has(name));
// 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.
const queryClient = new QueryClient();
export default function HomePage() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [missionName, setMissionName] = useState("TWL2_WoodyMyrk");
const [fogEnabled, setFogEnabled] = useState(true);
const threeContext = useRef<Record<string, any>>({});
useEffect(() => {
const canvas = canvasRef.current;
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
});
const textureLoader = new THREE.TextureLoader();
const gltfLoader = new GLTFLoader();
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75,
canvas.clientWidth / canvas.clientHeight,
0.1,
2000
);
function setupColor(tex, repeat = [1, 1]) {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping; // Still need this for tiling to work
tex.colorSpace = THREE.SRGBColorSpace;
tex.repeat.set(...repeat);
tex.anisotropy = renderer.capabilities.getMaxAnisotropy?.() ?? 16;
tex.generateMipmaps = true;
tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.magFilter = THREE.LinearFilter;
return tex;
}
function setupMask(data) {
const tex = new THREE.DataTexture(
data,
256,
256,
THREE.RedFormat, // 1 channel
THREE.UnsignedByteType // 8-bit
);
// Masks should stay linear
tex.colorSpace = THREE.NoColorSpace;
// Set tiling / sampling. For NPOT sizes, disable mips or use power-of-two.
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.generateMipmaps = false; // if width/height are not powers of two
tex.minFilter = THREE.LinearFilter; // avoid mips if generateMipmaps=false
tex.magFilter = THREE.LinearFilter;
tex.needsUpdate = true;
return tex;
}
const skyColor = "rgba(209, 237, 255, 1)";
const groundColor = "rgba(186, 200, 181, 1)";
const intensity = 2;
const light = new THREE.HemisphereLight(skyColor, groundColor, intensity);
scene.add(light);
// Free-look camera setup
camera.position.set(0, 100, 512);
const keys = {
w: false, a: false, s: false, d: false,
shift: false, space: false
};
const onKeyDown = (e) => {
if (e.code === "KeyW") keys.w = true;
if (e.code === "KeyA") keys.a = true;
if (e.code === "KeyS") keys.s = true;
if (e.code === "KeyD") keys.d = true;
if (e.code === "ShiftLeft" || e.code === "ShiftRight") keys.shift = true;
if (e.code === "Space") keys.space = true;
};
const onKeyUp = (e) => {
if (e.code === "KeyW") keys.w = false;
if (e.code === "KeyA") keys.a = false;
if (e.code === "KeyS") keys.s = false;
if (e.code === "KeyD") keys.d = false;
if (e.code === "ShiftLeft" || e.code === "ShiftRight") keys.shift = false;
if (e.code === "Space") keys.space = false;
};
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
// Mouse look controls
let isPointerLocked = false;
const euler = new THREE.Euler(0, 0, 0, 'YXZ');
const PI_2 = Math.PI / 2;
const onMouseMove = (e) => {
if (!isPointerLocked) return;
const movementX = e.movementX || 0;
const movementY = e.movementY || 0;
euler.setFromQuaternion(camera.quaternion);
euler.y -= movementX * 0.002;
euler.x -= movementY * 0.002;
euler.x = Math.max(-PI_2, Math.min(PI_2, euler.x));
camera.quaternion.setFromEuler(euler);
};
const onPointerLockChange = () => {
isPointerLocked = document.pointerLockElement === canvas;
};
const onCanvasClick = () => {
if (!isPointerLocked) {
canvas.requestPointerLock();
}
};
canvas.addEventListener('click', onCanvasClick);
document.addEventListener('pointerlockchange', onPointerLockChange);
document.addEventListener('mousemove', onMouseMove);
let moveSpeed = 2;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
// Adjust speed based on wheel direction
const delta = e.deltaY > 0 ? .75 : 1.25;
moveSpeed = Math.max(0.025, Math.min(4, moveSpeed * delta));
// Log the new speed for user feedback
console.log(`Movement speed: ${moveSpeed.toFixed(3)}`);
};
canvas.addEventListener('wheel', onWheel, { passive: false });
const animate = (t) => {
// Free-look movement
const forward = new THREE.Vector3();
camera.getWorldDirection(forward);
const right = new THREE.Vector3();
right.crossVectors(forward, camera.up).normalize();
let move = new THREE.Vector3();
if (keys.w) move.add(forward);
if (keys.s) move.sub(forward);
if (keys.a) move.sub(right);
if (keys.d) move.add(right);
if (keys.space) move.add(camera.up);
if (keys.shift) move.sub(camera.up);
if (move.lengthSq() > 0) {
move.normalize().multiplyScalar(moveSpeed);
camera.position.add(move);
}
if (resizeRendererToDisplaySize(renderer)) {
const canvas = renderer.domElement;
camera.aspect = canvas.clientWidth / canvas.clientHeight;
camera.updateProjectionMatrix();
}
renderer.render(scene, camera);
};
renderer.setAnimationLoop(animate);
threeContext.current = {
scene,
renderer,
camera,
setupColor,
setupMask,
textureLoader,
gltfLoader,
};
return () => {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
document.removeEventListener('pointerlockchange', onPointerLockChange);
document.removeEventListener('mousemove', onMouseMove);
canvas.removeEventListener('click', onCanvasClick);
canvas.removeEventListener('wheel', onWheel);
renderer.setAnimationLoop(null);
renderer.dispose();
};
}, []);
useEffect(() => {
const {
scene,
camera,
setupColor,
setupMask,
textureLoader,
gltfLoader,
} = threeContext.current;
let cancel = false;
let root: THREE.Group;
async function loadMap() {
const mission = await loadMission(missionName);
const terrainFile = getTerrainFile(mission);
const terrain = await loadTerrain(terrainFile);
const layerCount = terrain.textureNames.length;
const baseTextures = terrain.textureNames.map((name) => {
return setupColor(textureLoader.load(terrainTextureToUrl(name)));
});
const alphaTextures = terrain.alphaMaps.map((data) => setupMask(data));
const planeSize = 2048;
const geom = new THREE.PlaneGeometry(planeSize, planeSize, 256, 256);
geom.rotateX(-Math.PI / 2);
geom.rotateY(-Math.PI / 2);
// Find TerrainBlock properties for empty squares
let emptySquares: number[] | null = null;
for (const obj of iterObjects(mission.objects)) {
if (obj.className === "TerrainBlock") {
const emptySquaresStr = obj.properties.find((p: any) => p.target.name === "emptySquares")?.value;
if (emptySquaresStr) {
emptySquares = emptySquaresStr.split(" ").map((s: string) => parseInt(s))
}
break;
}
}
const f32HeightMap = uint16ToFloat32(terrain.heightMap);
// Create a visibility mask for empty squares
let visibilityMask: THREE.DataTexture | null = null;
if (emptySquares) {
const terrainSize = 256;
// Create a mask texture (1 = visible, 0 = invisible)
const maskData = new Uint8Array(terrainSize * terrainSize);
maskData.fill(255); // Start with everything visible
for (const squareId of emptySquares) {
// The squareId encodes position and count:
// Bits 0-7: X position (starting position)
// Bits 8-15: Y position
// Bits 16+: Count (number of consecutive horizontal squares)
const x = (squareId & 0xFF);
const y = (squareId >> 8) & 0xFF;
const count = (squareId >> 16);
for (let i = 0; i < count; i++) {
const px = x + i;
const py = y;
const index = py * terrainSize + px;
if (index >= 0 && index < maskData.length) {
maskData[index] = 0;
}
}
}
visibilityMask = new THREE.DataTexture(
maskData,
terrainSize,
terrainSize,
THREE.RedFormat,
THREE.UnsignedByteType
);
visibilityMask.colorSpace = THREE.NoColorSpace;
visibilityMask.wrapS = visibilityMask.wrapT = THREE.ClampToEdgeWrapping;
visibilityMask.magFilter = THREE.NearestFilter;
visibilityMask.minFilter = THREE.NearestFilter;
visibilityMask.needsUpdate = true;
}
const heightMap = new THREE.DataTexture(
f32HeightMap,
256,
256,
THREE.RedFormat,
THREE.FloatType
);
heightMap.colorSpace = THREE.NoColorSpace;
heightMap.generateMipmaps = false;
heightMap.needsUpdate = true;
// Start with a standard material; assign map to trigger USE_MAP/vMapUv
const mat = new THREE.MeshStandardMaterial({
// map: base0,
displacementMap: heightMap,
map: heightMap,
displacementScale: 2048,
depthWrite: true,
// displacementBias: -128,
});
// Inject our 4-layer blend before lighting
mat.onBeforeCompile = (shader) => {
// uniforms for 4 albedo maps + 3 alpha masks
baseTextures.forEach((tex, i) => {
shader.uniforms[`albedo${i}`] = { value: tex };
});
alphaTextures.forEach((tex, i) => {
if (i > 0) {
shader.uniforms[`mask${i}`] = { value: tex };
}
});
// Add visibility mask uniform if we have empty squares
if (visibilityMask) {
shader.uniforms.visibilityMask = { value: visibilityMask };
}
// Add per-texture tiling uniforms
baseTextures.forEach((tex, i) => {
shader.uniforms[`tiling${i}`] = {
value: Math.min(
512,
{ 0: 16, 1: 16, 2: 32, 3: 32, 4: 32, 5: 32 }[i]
),
};
});
// Declare our uniforms at the top of the fragment shader
shader.fragmentShader =
`
uniform sampler2D albedo0;
uniform sampler2D albedo1;
uniform sampler2D albedo2;
uniform sampler2D albedo3;
uniform sampler2D albedo4;
uniform sampler2D albedo5;
uniform sampler2D mask1;
uniform sampler2D mask2;
uniform sampler2D mask3;
uniform sampler2D mask4;
uniform sampler2D mask5;
uniform float tiling0;
uniform float tiling1;
uniform float tiling2;
uniform float tiling3;
uniform float tiling4;
uniform float tiling5;
${visibilityMask ? 'uniform sampler2D visibilityMask;' : ''}
` + shader.fragmentShader;
if (visibilityMask) {
const clippingPlaceholder = '#include <clipping_planes_fragment>';
shader.fragmentShader = shader.fragmentShader.replace(
clippingPlaceholder,
`${clippingPlaceholder}
// Early discard for invisible areas (before fog/lighting)
float visibility = texture2D(visibilityMask, vMapUv).r;
if (visibility < 0.5) {
discard;
}
`
);
}
// Replace the default map sampling block with our layered blend.
// We rely on vMapUv provided by USE_MAP.
shader.fragmentShader = shader.fragmentShader.replace(
"#include <map_fragment>",
`
// Sample base albedo layers (sRGB textures auto-decoded to linear)
vec2 baseUv = vMapUv;
vec3 c0 = texture2D(albedo0, baseUv * vec2(tiling0)).rgb;
${
layerCount > 1
? `vec3 c1 = texture2D(albedo1, baseUv * vec2(tiling1)).rgb;`
: ""
}
${
layerCount > 2
? `vec3 c2 = texture2D(albedo2, baseUv * vec2(tiling2)).rgb;`
: ""
}
${
layerCount > 3
? `vec3 c3 = texture2D(albedo3, baseUv * vec2(tiling3)).rgb;`
: ""
}
${
layerCount > 4
? `vec3 c4 = texture2D(albedo4, baseUv * vec2(tiling4)).rgb;`
: ""
}
${
layerCount > 5
? `vec3 c5 = texture2D(albedo5, baseUv * vec2(tiling5)).rgb;`
: ""
}
// Sample linear masks (use R channel)
float a1 = texture2D(mask1, baseUv).r;
${layerCount > 1 ? `float a2 = texture2D(mask2, baseUv).r;` : ""}
${layerCount > 2 ? `float a3 = texture2D(mask3, baseUv).r;` : ""}
${layerCount > 3 ? `float a4 = texture2D(mask4, baseUv).r;` : ""}
${layerCount > 4 ? `float a5 = texture2D(mask5, baseUv).r;` : ""}
// Bottom-up compositing: each mask tells how much the higher layer replaces lower
${layerCount > 1 ? `vec3 blended = mix(c0, c1, clamp(a1, 0.0, 1.0));` : ""}
${layerCount > 2 ? `blended = mix(blended, c2, clamp(a2, 0.0, 1.0));` : ""}
${layerCount > 3 ? `blended = mix(blended, c3, clamp(a3, 0.0, 1.0));` : ""}
${layerCount > 4 ? `blended = mix(blended, c4, clamp(a4, 0.0, 1.0));` : ""}
${layerCount > 5 ? `blended = mix(blended, c5, clamp(a5, 0.0, 1.0));` : ""}
// Assign to diffuseColor before lighting
diffuseColor.rgb = ${layerCount > 1 ? "blended" : "c0"};
`
);
};
root = new THREE.Group();
const terrainMesh = new THREE.Mesh(geom, mat);
root.add(terrainMesh);
for (const obj of iterObjects(mission.objects)) {
const getProperty = (name) => {
const property = obj.properties.find((p) => p.target.name === name);
// console.log({ name, property });
return property;
};
const getPosition = () => {
const position = getProperty("position")?.value ?? "0 0 0";
const [x, z, y] = position.split(" ").map((s) => parseFloat(s));
return [x, y, z];
};
const getScale = () => {
const scale = getProperty("scale")?.value ?? "1 1 1";
const [scaleX, scaleZ, scaleY] = scale
.split(" ")
.map((s) => parseFloat(s));
return [scaleX, scaleY, scaleZ];
};
const getRotation = (isInterior = false) => {
const rotation = getProperty("rotation")?.value ?? "1 0 0 0";
const [ax, az, ay, angle] = rotation
.split(" ")
.map((s) => parseFloat(s));
if (isInterior) {
// For interiors: Apply coordinate system transformation
// 1. Convert rotation axis from source coords (ax, az, ay) to Three.js coords
// 2. Apply -90 Y rotation to align coordinate systems
const sourceRotation = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(az, ay, ax),
-angle * (Math.PI / 180)
);
const coordSystemFix = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
Math.PI / 2
);
return coordSystemFix.multiply(sourceRotation);
} else {
// For other objects (terrain, etc)
return new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(ax, ay, -az),
angle * (Math.PI / 180)
);
}
};
switch (obj.className) {
case "TerrainBlock": {
const [x, y, z] = getPosition();
camera.position.set(x - 512, y + 256, z - 512);
const [scaleX, scaleY, scaleZ] = getScale();
const q = getRotation();
terrainMesh.position.set(x, y, z);
terrainMesh.scale.set(scaleX, scaleY, scaleZ);
terrainMesh.quaternion.copy(q);
break;
}
case "Sky": {
const materialList = getProperty("materialList")?.value;
if (materialList) {
const detailMapList = await loadDetailMapList(materialList);
const skyLoader = new THREE.CubeTextureLoader();
const fallbackUrl = `${BASE_URL}/black.png`;
const texture = skyLoader.load([
getUrlForPath(detailMapList[1], fallbackUrl), // +x
getUrlForPath(detailMapList[3], fallbackUrl), // -x
getUrlForPath(detailMapList[4], fallbackUrl), // +y
getUrlForPath(detailMapList[5], fallbackUrl), // -y
getUrlForPath(detailMapList[0], fallbackUrl), // +z
getUrlForPath(detailMapList[2], fallbackUrl), // -z
]);
scene.background = texture;
}
const fogDistance = getProperty("fogDistance")?.value;
const fogColor = getProperty("fogColor")?.value;
if (fogDistance && fogColor) {
const distance = parseFloat(fogDistance);
const [r, g, b] = fogColor.split(" ").map((s) => parseFloat(s));
const color = new THREE.Color().setRGB(r, g, b);
const fog = new THREE.Fog(color, 0, distance * 2);
if (fogEnabled) {
scene.fog = fog;
} else {
scene._fog = fog;
}
}
break;
}
case "InteriorInstance": {
const [z, y, x] = getPosition();
const [scaleX, scaleY, scaleZ] = getScale();
const q = getRotation(true);
const interiorFile = getProperty("interiorFile").value;
gltfLoader.load(interiorToUrl(interiorFile), (gltf) => {
gltf.scene.traverse((o) => {
if (o.material?.name) {
const name = o.material.name;
try {
const tex = textureLoader.load(interiorTextureToUrl(name));
o.material.map = setupColor(tex);
} catch (err) {
console.error(err);
}
o.material.needsUpdate = true;
}
});
const interior = gltf.scene;
interior.position.set(x - 1024, y, z - 1024);
interior.scale.set(-scaleX, scaleY, -scaleZ);
interior.quaternion.copy(q);
root.add(interior);
});
break;
}
case "WaterBlock": {
const [z, y, x] = getPosition();
const [scaleZ, scaleY, scaleX] = getScale();
const q = getRotation(true);
const surfaceTexture =
getProperty("surfaceTexture")?.value ?? "liquidTiles/BlueWater";
const geometry = new THREE.BoxGeometry(scaleZ, scaleY, scaleX);
const material = new THREE.MeshStandardMaterial({
map: setupColor(
textureLoader.load(textureToUrl(surfaceTexture)),
[8, 8]
),
// transparent: true,
opacity: 0.8,
});
const water = new THREE.Mesh(geometry, material);
water.position.set(
x - 1024 + scaleX / 2,
y + scaleY / 2,
z - 1024 + scaleZ / 2
);
water.quaternion.copy(q);
root.add(water);
break;
}
}
}
if (cancel) {
return;
}
scene.add(root);
}
loadMap();
return () => {
cancel = true;
root.removeFromParent();
};
}, [missionName]);
useEffect(() => {
const { scene } = threeContext.current;
if (fogEnabled) {
scene.fog = scene._fog ?? null;
scene._fog = null;
scene.needsUpdate = true;
} else {
scene._fog = scene.fog;
scene.fog = null;
scene.needsUpdate = true;
}
}, [fogEnabled]);
const [fogEnabled, setFogEnabled] = useState(false);
return (
<main>
<canvas ref={canvasRef} id="canvas" />
<div id="controls">
<select
id="missionList"
value={missionName}
onChange={(e) => setMissionName(e.target.value)}
>
{missions.map((missionName) => (
<option key={missionName}>{missionName}</option>
))}
</select>
<div className="CheckboxField">
<input
id="fogInput"
type="checkbox"
checked={fogEnabled}
onChange={(event) => {
setFogEnabled(event.target.checked);
}}
<SettingsProvider fogEnabled={fogEnabled}>
<QueryClientProvider client={queryClient}>
<main>
<Canvas>
<ObserverControls />
<Mission key={missionName} name={missionName} />
<PerspectiveCamera
makeDefault
position={[-512, 256, -512]}
fov={90}
/>
</Canvas>
<InspectorControls
missionName={missionName}
onChangeMission={setMissionName}
fogEnabled={fogEnabled}
onChangeFogEnabled={setFogEnabled}
/>
<label htmlFor="fogInput">Fog?</label>
</div>
</div>
</main>
</main>
</QueryClientProvider>
</SettingsProvider>
);
}

19
app/renderObject.tsx Normal file
View file

@ -0,0 +1,19 @@
import { ConsoleObject } from "@/src/mission";
import { TerrainBlock } from "./TerrainBlock";
import { WaterBlock } from "./WaterBlock";
import { SimGroup } from "./SimGroup";
import { InteriorInstance } from "./InteriorInstance";
import { Sky } from "./Sky";
const componentMap = {
SimGroup,
TerrainBlock,
WaterBlock,
InteriorInstance,
Sky,
};
export function renderObject(object: ConsoleObject, key: string | number) {
const Component = componentMap[object.className];
return Component ? <Component key={key} object={object} /> : null;
}

View file

@ -2,6 +2,7 @@ html,
body {
margin: 0;
padding: 0;
background: black;
}
html {
@ -10,8 +11,7 @@ html {
font-size: 100%;
}
#canvas {
display: block;
main {
width: 100vw;
height: 100vh;
}