From fda9f6a3d344b270117a3d07761a39a2c3ea3a03 Mon Sep 17 00:00:00 2001 From: Brian Beck Date: Wed, 3 Dec 2025 14:35:06 -0800 Subject: [PATCH] add ForceFieldBare and a useDatablock hook (#14) --- src/components/ForceFieldBare.tsx | 316 +++++++++++++++++++++++++++++ src/components/Mission.tsx | 29 ++- src/components/RuntimeProvider.tsx | 26 +++ src/components/TickProvider.tsx | 13 +- src/components/renderObject.tsx | 2 + src/components/useDatablock.ts | 18 ++ src/components/useIflTexture.ts | 6 +- src/loaders.ts | 13 +- src/mission.ts | 3 +- 9 files changed, 406 insertions(+), 20 deletions(-) create mode 100644 src/components/ForceFieldBare.tsx create mode 100644 src/components/RuntimeProvider.tsx create mode 100644 src/components/useDatablock.ts diff --git a/src/components/ForceFieldBare.tsx b/src/components/ForceFieldBare.tsx new file mode 100644 index 00000000..a0e4a40c --- /dev/null +++ b/src/components/ForceFieldBare.tsx @@ -0,0 +1,316 @@ +import { memo, Suspense, useEffect, useMemo, useRef } from "react"; +import { useTexture } from "@react-three/drei"; +import { useFrame } from "@react-three/fiber"; +import { + AdditiveBlending, + BoxGeometry, + Color, + DoubleSide, + NoColorSpace, + RepeatWrapping, + ShaderMaterial, + Texture, + Vector2, +} from "three"; +import type { TorqueObject } from "../torqueScript"; +import { getPosition, getProperty, getRotation, getScale } from "../mission"; +import { textureToUrl } from "../loaders"; +import { useSettings } from "./SettingsProvider"; +import { useDatablock } from "./useDatablock"; + +/** + * Get texture URLs from datablock. + * Datablock defines textures as texture[0], texture[1], etc. which become + * properties texture0, texture1, etc. (TorqueScript array indexing flattens to suffix) + */ +function getTextureUrls( + datablock: TorqueObject | undefined, + numFrames: number, +): string[] { + const textures: string[] = []; + for (let i = 0; i < numFrames; i++) { + // TorqueScript array indexing: texture[0] -> texture0 + const texturePath = getProperty(datablock, `texture${i}`); + if (texturePath) { + textures.push(textureToUrl(texturePath)); + } + } + return textures; +} + +function parseColor(colorStr: string): [number, number, number] { + const parts = colorStr.split(" ").map((s) => parseFloat(s)); + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; +} + +// Vertex shader +const vertexShader = ` +varying vec2 vUv; + +void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +`; + +// Fragment shader - handles frame animation, UV scrolling, and color tinting +// NOTE: Shader supports up to 5 texture frames (hardcoded samplers) +const fragmentShader = ` +uniform sampler2D frame0; +uniform sampler2D frame1; +uniform sampler2D frame2; +uniform sampler2D frame3; +uniform sampler2D frame4; +uniform int currentFrame; +uniform float vScroll; +uniform vec2 uvScale; +uniform vec3 tintColor; +uniform float opacity; + +varying vec2 vUv; + +// FIXME: This gamma correction may not be accurate. Tribes 2 had no gamma correction; +// Three.js applies gamma on output, so we pre-darken to compensate. The result is +// close but not quite right - the force field is still slightly more opaque than in T2. +vec3 srgbToLinear(vec3 srgb) { + return pow(srgb, vec3(2.2)); +} + +void main() { + // Scale and scroll UVs + vec2 scrolledUv = vec2(vUv.x * uvScale.x, vUv.y * uvScale.y + vScroll); + + // Sample the current frame + vec4 texColor; + if (currentFrame == 0) { + texColor = texture2D(frame0, scrolledUv); + } else if (currentFrame == 1) { + texColor = texture2D(frame1, scrolledUv); + } else if (currentFrame == 2) { + texColor = texture2D(frame2, scrolledUv); + } else if (currentFrame == 3) { + texColor = texture2D(frame3, scrolledUv); + } else { + texColor = texture2D(frame4, scrolledUv); + } + + // Apply color tint with constant opacity (like Tribes 2's GL_MODULATE) + vec3 finalColor = texColor.rgb * tintColor; + + // Pre-darken to counteract renderer's sRGB gamma encoding + // This makes additive blending behave like Tribes 2's non-gamma-corrected output + finalColor = srgbToLinear(finalColor); + + // FIXME: Halving opacity is a rough approximation to compensate for front+back faces + // both contributing (BoxGeometry with DoubleSide causes additive stacking that Tribes 2's + // thin quads didn't have). This doesn't account for viewing angles where more faces are visible. + gl_FragColor = vec4(finalColor, opacity * 0.5); +} +`; + +function setupForceFieldTexture(texture: Texture) { + texture.wrapS = texture.wrapT = RepeatWrapping; + // FIXME: Using NoColorSpace to treat textures as raw linear values like Tribes 2 did, + // but the interaction with the renderer's sRGB output and shader gamma correction + // may not be fully correct. The force field appears close but not identical to T2. + texture.colorSpace = NoColorSpace; + texture.flipY = false; + texture.needsUpdate = true; +} + +/** + * Creates a box geometry with origin at corner (like Torque) instead of center. + * Handles disposal automatically. + */ +function useCornerBoxGeometry(scale: [number, number, number]) { + const geometry = useMemo(() => { + const [x, y, z] = scale; + const geom = new BoxGeometry(x, y, z); + geom.translate(x / 2, y / 2, z / 2); + return geom; + }, [scale]); + + useEffect(() => { + return () => geometry.dispose(); + }, [geometry]); + + return geometry; +} + +interface ForceFieldGeometryProps { + scale: [number, number, number]; + color: [number, number, number]; + baseTranslucency: number; +} + +interface ForceFieldMeshProps extends ForceFieldGeometryProps { + textureUrls: string[]; + numFrames: number; + framesPerSec: number; + scrollSpeed: number; + umapping: number; + vmapping: number; +} + +function ForceFieldMesh({ + scale, + color, + baseTranslucency, + textureUrls, + numFrames, + framesPerSec, + scrollSpeed, + umapping, + vmapping, +}: ForceFieldMeshProps) { + const { animationEnabled } = useSettings(); + const geometry = useCornerBoxGeometry(scale); + const textures = useTexture(textureUrls, (textures) => { + textures.forEach((tex) => setupForceFieldTexture(tex)); + }); + + // Create shader material once (uniforms updated in useFrame) + const material = useMemo(() => { + // UV scale based on the two largest dimensions (force fields are thin planes) + const dims = [...scale].sort((a, b) => b - a); + const uvScale = new Vector2(dims[0] * umapping, dims[1] * vmapping); + + // Use first texture as fallback for unused slots + const fallbackTex = textures[0]; + return new ShaderMaterial({ + uniforms: { + frame0: { value: textures[0] ?? fallbackTex }, + frame1: { value: textures[1] ?? fallbackTex }, + frame2: { value: textures[2] ?? fallbackTex }, + frame3: { value: textures[3] ?? fallbackTex }, + frame4: { value: textures[4] ?? fallbackTex }, + currentFrame: { value: 0 }, + vScroll: { value: 0 }, + uvScale: { value: uvScale }, + tintColor: { value: new Color(...color) }, + opacity: { value: baseTranslucency }, + }, + vertexShader, + fragmentShader, + transparent: true, + blending: AdditiveBlending, + side: DoubleSide, + depthWrite: false, + }); + }, [textures, scale, umapping, vmapping, color, baseTranslucency]); + + useEffect(() => { + return () => material.dispose(); + }, [material]); + + // Animation state + const elapsedRef = useRef(0); + + // Animate frame and scroll + useFrame((_, delta) => { + if (!animationEnabled) { + elapsedRef.current = 0; + material.uniforms.currentFrame.value = 0; + material.uniforms.vScroll.value = 0; + return; + } + + elapsedRef.current += delta; + + // Frame animation + material.uniforms.currentFrame.value = + Math.floor(elapsedRef.current * framesPerSec) % numFrames; + + // UV scrolling + material.uniforms.vScroll.value = elapsedRef.current * scrollSpeed; + }); + + return ; +} + +function ForceFieldFallback({ + scale, + color, + baseTranslucency, +}: ForceFieldGeometryProps) { + const geometry = useCornerBoxGeometry(scale); + + return ( + + + + ); +} + +export const ForceFieldBare = memo(function ForceFieldBare({ + object, +}: { + object: TorqueObject; +}) { + const position = useMemo(() => getPosition(object), [object]); + const quaternion = useMemo(() => getRotation(object), [object]); + const scale = useMemo(() => getScale(object), [object]); + + // Look up the datablock - rendering properties like color, translucency, etc. + // are stored on the datablock, not the instance (see forceFieldBare.cc) + const datablock = useDatablock(getProperty(object, "dataBlock")); + + // All rendering properties come from the datablock + const colorStr = getProperty(datablock, "color"); + const color = useMemo( + () => + colorStr ? parseColor(colorStr) : ([1, 1, 1] as [number, number, number]), + [colorStr], + ); + + const baseTranslucency = + parseFloat(getProperty(datablock, "baseTranslucency")) || 1; + const numFrames = parseInt(getProperty(datablock, "numFrames"), 10) || 1; + const framesPerSec = parseFloat(getProperty(datablock, "framesPerSec")) || 1; + const scrollSpeed = parseFloat(getProperty(datablock, "scrollSpeed")) || 0; + const umapping = parseFloat(getProperty(datablock, "umapping")) || 1; + const vmapping = parseFloat(getProperty(datablock, "vmapping")) || 1; + + const textureUrls = useMemo( + () => getTextureUrls(datablock, numFrames), + [datablock, numFrames], + ); + + // Don't render if we have no textures + if (textureUrls.length === 0) { + return null; + } + + return ( + + + } + > + + + + ); +}); diff --git a/src/components/Mission.tsx b/src/components/Mission.tsx index ee8727a8..b78d9511 100644 --- a/src/components/Mission.tsx +++ b/src/components/Mission.tsx @@ -5,12 +5,13 @@ import { type ParsedMission } from "../mission"; import { createScriptLoader } from "../torqueScript/scriptLoader.browser"; import { renderObject } from "./renderObject"; import { memo, useEffect, useState } from "react"; -import { TickProvider } from "./TickProvider"; +import { RuntimeProvider } from "./RuntimeProvider"; import { createScriptCache, FileSystemHandler, runServer, TorqueObject, + TorqueRuntime, } from "../torqueScript"; import { getResourceKey, @@ -46,11 +47,19 @@ function useParsedMission(name: string) { }); } +interface ExecutedMissionState { + missionGroup: TorqueObject | undefined; + runtime: TorqueRuntime | undefined; +} + function useExecutedMission( missionName: string, parsedMission: ParsedMission | undefined, -) { - const [missionGroup, setMissionGroup] = useState(); +): ExecutedMissionState { + const [state, setState] = useState({ + missionGroup: undefined, + runtime: undefined, + }); useEffect(() => { if (!parsedMission) { @@ -83,7 +92,7 @@ function useExecutedMission( }, onMissionLoadDone: () => { const missionGroup = runtime.getObjectByName("MissionGroup"); - setMissionGroup(missionGroup); + setState({ missionGroup, runtime }); }, }); @@ -93,7 +102,7 @@ function useExecutedMission( }; }, [missionName, parsedMission]); - return missionGroup; + return state; } interface MissionProps { @@ -106,8 +115,8 @@ export const Mission = memo(function Mission({ onLoadingChange, }: MissionProps) { const { data: parsedMission } = useParsedMission(name); - const missionGroup = useExecutedMission(name, parsedMission); - const isLoading = !missionGroup; + const { missionGroup, runtime } = useExecutedMission(name, parsedMission); + const isLoading = !missionGroup || !runtime; useEffect(() => { onLoadingChange?.(isLoading); @@ -117,5 +126,9 @@ export const Mission = memo(function Mission({ return null; } - return {renderObject(missionGroup)}; + return ( + + {renderObject(missionGroup)} + + ); }); diff --git a/src/components/RuntimeProvider.tsx b/src/components/RuntimeProvider.tsx new file mode 100644 index 00000000..994915aa --- /dev/null +++ b/src/components/RuntimeProvider.tsx @@ -0,0 +1,26 @@ +import { createContext, ReactNode, useContext } from "react"; +import type { TorqueRuntime } from "../torqueScript"; +import { TickProvider } from "./TickProvider"; + +const RuntimeContext = createContext(null); + +interface RuntimeProviderProps { + runtime: TorqueRuntime; + children: ReactNode; +} + +export function RuntimeProvider({ runtime, children }: RuntimeProviderProps) { + return ( + + {children} + + ); +} + +export function useRuntime(): TorqueRuntime { + const runtime = useContext(RuntimeContext); + if (!runtime) { + throw new Error("useRuntime must be used within a RuntimeProvider"); + } + return runtime; +} diff --git a/src/components/TickProvider.tsx b/src/components/TickProvider.tsx index 8a440b30..322aad9b 100644 --- a/src/components/TickProvider.tsx +++ b/src/components/TickProvider.tsx @@ -9,19 +9,24 @@ import { } from "react"; import { useFrame } from "@react-three/fiber"; +/** Ticks per second, matching the Torque engine tick rate. */ export const TICK_RATE = 32; const TICK_INTERVAL = 1 / TICK_RATE; -type TickCallback = (tick: number) => void; +export type TickCallback = (tick: number) => void; -type TickContextValue = { +interface TickContextValue { subscribe: (callback: TickCallback) => () => void; getTick: () => number; -}; +} const TickContext = createContext(null); -export function TickProvider({ children }: { children: ReactNode }) { +interface TickProviderProps { + children: ReactNode; +} + +export function TickProvider({ children }: TickProviderProps) { const callbacksRef = useRef | undefined>(undefined); const accumulatorRef = useRef(0); const tickRef = useRef(0); diff --git a/src/components/renderObject.tsx b/src/components/renderObject.tsx index 358f8fdb..e4b1978a 100644 --- a/src/components/renderObject.tsx +++ b/src/components/renderObject.tsx @@ -12,10 +12,12 @@ import { Turret } from "./Turret"; import { AudioEmitter } from "./AudioEmitter"; import { WayPoint } from "./WayPoint"; import { Camera } from "./Camera"; +import { ForceFieldBare } from "./ForceFieldBare"; const componentMap = { AudioEmitter, Camera, + ForceFieldBare, InteriorInstance, Item, SimGroup, diff --git a/src/components/useDatablock.ts b/src/components/useDatablock.ts new file mode 100644 index 00000000..5493e52a --- /dev/null +++ b/src/components/useDatablock.ts @@ -0,0 +1,18 @@ +import type { TorqueObject } from "../torqueScript"; +import { useRuntime } from "./RuntimeProvider"; + +/** + * Look up a datablock by name from the runtime. Use with getProperty/getInt/getFloat. + * + * FIXME: This is not currently reactive! If new datablocks are defined, this + * won't find them. We'd need to add an event/subscription system to the runtime + * that fires when new datablocks are defined. Technically we should do the same + * for the scene graph. + */ +export function useDatablock( + name: string | undefined, +): TorqueObject | undefined { + const runtime = useRuntime(); + if (!name) return undefined; + return runtime.state.datablocks.get(name); +} diff --git a/src/components/useIflTexture.ts b/src/components/useIflTexture.ts index 8e507e88..fcb351d9 100644 --- a/src/components/useIflTexture.ts +++ b/src/components/useIflTexture.ts @@ -8,7 +8,7 @@ import { SRGBColorSpace, Texture, } from "three"; -import { loadImageFrameList, textureFrameToUrl } from "../loaders"; +import { iflTextureToUrl, loadImageFrameList } from "../loaders"; import { useTick } from "./TickProvider"; import { useSettings } from "./SettingsProvider"; @@ -119,8 +119,8 @@ export function useIflTexture(iflPath: string): Texture { }); const textureUrls = useMemo( - () => frames.map((frame) => textureFrameToUrl(frame.name)), - [frames], + () => frames.map((frame) => iflTextureToUrl(frame.name, iflPath)), + [frames, iflPath], ); const textures = useTexture(textureUrls); diff --git a/src/loaders.ts b/src/loaders.ts index 8c57399f..e9da5248 100644 --- a/src/loaders.ts +++ b/src/loaders.ts @@ -6,6 +6,7 @@ import { getStandardTextureResourceKey, } from "./manifest"; import { parseMissionScript } from "./mission"; +import { normalizePath } from "./stringUtils"; import { parseTerrainBuffer } from "./terrain"; export const BASE_URL = "/t2-mapper"; @@ -50,10 +51,14 @@ export function terrainTextureToUrl(name: string) { return getUrlForPath(resourceKey, FALLBACK_TEXTURE_URL); } -export function textureFrameToUrl(fileName: string) { - const resourceKey = getStandardTextureResourceKey( - `textures/skins/${fileName}`, - ); +export function iflTextureToUrl(name: string, iflPath: string) { + // Paths inside IFL files are relative to the IFL file, so we need to prepend + // the IFL's dir (if any) to the parsed paths. + const pathParts = normalizePath(iflPath).split("/"); + const iflDir = + pathParts.length > 1 ? pathParts.slice(0, -1).join("/") + "/" : ""; + const finalPath = `${iflDir}${name}`; + const resourceKey = getStandardTextureResourceKey(finalPath); return getUrlForPath(resourceKey, FALLBACK_TEXTURE_URL); } diff --git a/src/mission.ts b/src/mission.ts index aa45c6b6..f23ff868 100644 --- a/src/mission.ts +++ b/src/mission.ts @@ -191,7 +191,8 @@ export function* iterObjects( } } -export function getProperty(obj: TorqueObject, name: string): any { +export function getProperty(obj: TorqueObject | undefined, name: string): any { + if (!obj) return undefined; return obj[name.toLowerCase()]; }