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

7
src/arrayUtils.ts Normal file
View file

@ -0,0 +1,7 @@
export 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;
}

69
src/loaders.ts Normal file
View file

@ -0,0 +1,69 @@
import { getActualResourcePath, getSource } from "./manifest";
import { parseMissionScript } from "./mission";
import { parseTerrainBuffer } from "./terrain";
export const BASE_URL = "/t2-mapper";
export const RESOURCE_ROOT_URL = `${BASE_URL}/base/`;
export 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}`;
}
}
export function interiorToUrl(name: string) {
const difUrl = getUrlForPath(`interiors/${name}`);
return difUrl.replace(/\.dif$/i, ".gltf");
}
export function terrainTextureToUrl(name: string) {
name = name.replace(/^terrain\./, "");
return getUrlForPath(`textures/terrain/${name}.png`, `${BASE_URL}/black.png`);
}
export function interiorTextureToUrl(name: string) {
name = name.replace(/\.\d+$/, "");
return getUrlForPath(`textures/${name}.png`);
}
export function textureToUrl(name: string) {
try {
return getUrlForPath(`textures/${name}.png`);
} catch (err) {
return `${BASE_URL}/black.png`;
}
}
export 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`);
}
export async function loadMission(name: string) {
const res = await fetch(getUrlForPath(`missions/${name}.mis`));
const missionScript = await res.text();
return parseMissionScript(missionScript);
}
export async function loadTerrain(fileName: string) {
const res = await fetch(getUrlForPath(`terrains/${fileName}`));
const terrainBuffer = await res.arrayBuffer();
return parseTerrainBuffer(terrainBuffer);
}

View file

@ -1,4 +1,5 @@
import parser from "@/generated/mission.cjs";
import { Quaternion, Vector3 } from "three";
const definitionComment = /^ (DisplayName|MissionTypes) = (.+)$/;
const sectionBeginComment = /^--- ([A-Z ]+) BEGIN ---$/;
@ -175,7 +176,8 @@ export function parseMissionScript(script) {
};
}
type Mission = ReturnType<typeof parseMissionScript>;
export type Mission = ReturnType<typeof parseMissionScript>;
export type ConsoleObject = Mission["objects"][number];
export function* iterObjects(objectList) {
for (const obj of objectList) {
@ -186,18 +188,62 @@ export function* iterObjects(objectList) {
}
}
export function getTerrainFile(mission: Mission) {
let terrainBlock;
export function getTerrainBlock(mission: Mission): ConsoleObject {
for (const obj of iterObjects(mission.objects)) {
if (obj.className === "TerrainBlock") {
terrainBlock = obj;
break;
return obj;
}
}
if (!terrainBlock) {
throw new Error("Error!");
}
throw new Error("No TerrainBlock found!");
}
export function getTerrainFile(mission: Mission) {
const terrainBlock = getTerrainBlock(mission);
return terrainBlock.properties.find(
(prop) => prop.target.name === "terrainFile"
).value;
}
export function getProperty(obj: ConsoleObject, name: string) {
const property = obj.properties.find((p) => p.target.name === name);
// console.log({ name, property });
return property;
}
export function getPosition(obj: ConsoleObject): [number, number, number] {
const position = getProperty(obj, "position")?.value ?? "0 0 0";
const [x, z, y] = position.split(" ").map((s) => parseFloat(s));
return [x, y, z];
}
export function getScale(obj: ConsoleObject): [number, number, number] {
const scale = getProperty(obj, "scale")?.value ?? "1 1 1";
const [scaleX, scaleZ, scaleY] = scale.split(" ").map((s) => parseFloat(s));
return [scaleX, scaleY, scaleZ];
}
export function getRotation(obj: ConsoleObject, isInterior = false) {
const rotation = getProperty(obj, "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 Quaternion().setFromAxisAngle(
new Vector3(az, ay, ax),
-angle * (Math.PI / 180)
);
const coordSystemFix = new Quaternion().setFromAxisAngle(
new Vector3(0, 1, 0),
Math.PI / 2
);
return coordSystemFix.multiply(sourceRotation);
} else {
// For other objects (terrain, etc)
return new Quaternion().setFromAxisAngle(
new Vector3(ax, ay, -az),
angle * (Math.PI / 180)
);
}
}

168
src/textureUtils.ts Normal file
View file

@ -0,0 +1,168 @@
import {
DataTexture,
LinearFilter,
LinearMipmapLinearFilter,
NoColorSpace,
RedFormat,
RepeatWrapping,
SRGBColorSpace,
UnsignedByteType,
} from "three";
export function setupColor(tex, repeat = [1, 1]) {
tex.wrapS = tex.wrapT = RepeatWrapping;
tex.colorSpace = SRGBColorSpace;
tex.repeat.set(...repeat);
tex.anisotropy = 16;
tex.generateMipmaps = true;
tex.minFilter = LinearMipmapLinearFilter;
tex.magFilter = LinearFilter;
tex.needsUpdate = true;
return tex;
}
export function setupMask(data) {
const tex = new DataTexture(
data,
256,
256,
RedFormat, // 1 channel
UnsignedByteType // 8-bit
);
// Masks should stay linear
tex.colorSpace = NoColorSpace;
// Set tiling / sampling. For NPOT sizes, disable mips or use power-of-two.
tex.wrapS = tex.wrapT = RepeatWrapping;
tex.generateMipmaps = false; // if width/height are not powers of two
tex.minFilter = LinearFilter; // avoid mips if generateMipmaps=false
tex.magFilter = LinearFilter;
tex.needsUpdate = true;
return tex;
}
export function updateTerrainTextureShader({
shader,
baseTextures,
alphaTextures,
visibilityMask,
}) {
const layerCount = baseTextures.length;
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"};
`
);
}