mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-01-19 12:14:47 +00:00
add ForceFieldBare and a useDatablock hook (#14)
This commit is contained in:
parent
10984c3c0f
commit
fda9f6a3d3
316
src/components/ForceFieldBare.tsx
Normal file
316
src/components/ForceFieldBare.tsx
Normal file
|
|
@ -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 <mesh geometry={geometry} material={material} />;
|
||||
}
|
||||
|
||||
function ForceFieldFallback({
|
||||
scale,
|
||||
color,
|
||||
baseTranslucency,
|
||||
}: ForceFieldGeometryProps) {
|
||||
const geometry = useCornerBoxGeometry(scale);
|
||||
|
||||
return (
|
||||
<mesh geometry={geometry}>
|
||||
<meshBasicMaterial
|
||||
color={new Color(...color)}
|
||||
transparent
|
||||
opacity={baseTranslucency * 0.5}
|
||||
blending={AdditiveBlending}
|
||||
side={DoubleSide}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<group position={position} quaternion={quaternion}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<ForceFieldFallback
|
||||
scale={scale}
|
||||
color={color}
|
||||
baseTranslucency={baseTranslucency}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ForceFieldMesh
|
||||
scale={scale}
|
||||
color={color}
|
||||
baseTranslucency={baseTranslucency}
|
||||
textureUrls={textureUrls}
|
||||
numFrames={numFrames}
|
||||
framesPerSec={framesPerSec}
|
||||
scrollSpeed={scrollSpeed}
|
||||
umapping={umapping}
|
||||
vmapping={vmapping}
|
||||
/>
|
||||
</Suspense>
|
||||
</group>
|
||||
);
|
||||
});
|
||||
|
|
@ -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<TorqueObject | undefined>();
|
||||
): ExecutedMissionState {
|
||||
const [state, setState] = useState<ExecutedMissionState>({
|
||||
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 <TickProvider>{renderObject(missionGroup)}</TickProvider>;
|
||||
return (
|
||||
<RuntimeProvider runtime={runtime}>
|
||||
{renderObject(missionGroup)}
|
||||
</RuntimeProvider>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
26
src/components/RuntimeProvider.tsx
Normal file
26
src/components/RuntimeProvider.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { createContext, ReactNode, useContext } from "react";
|
||||
import type { TorqueRuntime } from "../torqueScript";
|
||||
import { TickProvider } from "./TickProvider";
|
||||
|
||||
const RuntimeContext = createContext<TorqueRuntime | null>(null);
|
||||
|
||||
interface RuntimeProviderProps {
|
||||
runtime: TorqueRuntime;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RuntimeProvider({ runtime, children }: RuntimeProviderProps) {
|
||||
return (
|
||||
<RuntimeContext.Provider value={runtime}>
|
||||
<TickProvider>{children}</TickProvider>
|
||||
</RuntimeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRuntime(): TorqueRuntime {
|
||||
const runtime = useContext(RuntimeContext);
|
||||
if (!runtime) {
|
||||
throw new Error("useRuntime must be used within a RuntimeProvider");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
|
@ -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<TickContextValue | null>(null);
|
||||
|
||||
export function TickProvider({ children }: { children: ReactNode }) {
|
||||
interface TickProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function TickProvider({ children }: TickProviderProps) {
|
||||
const callbacksRef = useRef<Set<TickCallback> | undefined>(undefined);
|
||||
const accumulatorRef = useRef(0);
|
||||
const tickRef = useRef(0);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
18
src/components/useDatablock.ts
Normal file
18
src/components/useDatablock.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue