add terrain tiling, tweak fog

This commit is contained in:
Brian Beck 2025-12-04 21:25:38 -08:00
parent 2a730b8a44
commit d320fbd694
15 changed files with 473 additions and 239 deletions

View file

@ -1,7 +1,7 @@
import { Suspense, useMemo, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useCubeTexture } from "@react-three/drei";
import { Color, ShaderMaterial, BackSide, Euler } from "three";
import { Color, ShaderMaterial, BackSide, Euler, ShaderChunk } from "three";
import type { TorqueObject } from "../torqueScript";
import { getFloat, getInt, getProperty } from "../mission";
import { useSettings } from "./SettingsProvider";
@ -11,6 +11,41 @@ import { CloudLayers } from "./CloudLayers";
const FALLBACK_TEXTURE_URL = `${BASE_URL}/black.png`;
/**
* Tribes 2 fog formula (from sceneState.cc getHaze):
* fogScale = 1.0 / (visibleDistance - fogDistance)
* distFactor = (dist - fogDistance) * fogScale - 1.0
* haze = 1.0 - distFactor * distFactor
*
* This creates an "ease-in" quadratic curve where fog builds slowly at first,
* then accelerates toward visibleDistance.
*
* Set USE_QUADRATIC_FOG to true to use this formula, false to use Three.js linear fog.
*/
const USE_QUADRATIC_FOG = false;
function installQuadraticFogShader() {
ShaderChunk.fog_fragment = `
#ifdef USE_FOG
float fogFactor = 0.0;
if (vFogDepth > fogNear) {
if (vFogDepth >= fogFar) {
fogFactor = 1.0;
} else {
float fogScale = 1.0 / (fogFar - fogNear);
float distFactor = (vFogDepth - fogNear) * fogScale - 1.0;
fogFactor = 1.0 - distFactor * distFactor;
}
}
gl_FragColor.rgb = mix(gl_FragColor.rgb, fogColor, fogFactor);
#endif
`;
}
if (USE_QUADRATIC_FOG) {
installQuadraticFogShader();
}
/**
* Load a .dml file, used to list the textures for different faces of a skybox.
*/
@ -24,11 +59,13 @@ function useDetailMapList(name: string) {
export function SkyBox({
materialList,
fogColor,
fogDistance,
fogNear,
fogFar,
}: {
materialList: string;
fogColor?: Color;
fogDistance?: number;
fogNear?: number;
fogFar?: number;
}) {
const { data: detailMapList } = useDetailMapList(materialList);
@ -59,13 +96,14 @@ export function SkyBox({
// Create a shader material for the skybox with fog
const materialRef = useRef<ShaderMaterial>(null!);
const hasFog = !!fogColor && !!fogDistance;
const hasFog = !!fogColor && fogNear != null && fogFar != null;
const shaderMaterial = useMemo(() => {
if (!hasFog) {
return null;
}
// Skybox fog blends toward horizon
return new ShaderMaterial({
uniforms: {
skybox: { value: skyBox },
@ -75,12 +113,9 @@ export function SkyBox({
varying vec3 vDirection;
void main() {
// Use position directly as direction (no world transform needed)
vDirection = position;
// Transform position but ignore translation
vec4 pos = projectionMatrix * mat4(mat3(modelViewMatrix)) * vec4(position, 1.0);
gl_Position = pos.xyww; // Set depth to far plane
gl_Position = pos.xyww;
}
`,
fragmentShader: `
@ -95,13 +130,12 @@ export function SkyBox({
direction = vec3(direction.z, direction.y, direction.x);
vec4 skyColor = textureCube(skybox, direction);
// Calculate fog factor based on vertical direction
// Fog increases toward and below horizon
// direction.y: -1 = straight down, 0 = horizon, 1 = straight up
// 100% fog from bottom to horizon, then fade from horizon (0) to 0.4
float fogFactor = smoothstep(0.0, 0.4, direction.y);
// Use smoothstep for gradual transition (matches Three.js linear fog feel)
float fogFactor = 1.0 - smoothstep(-0.1, 0.5, direction.y);
// Mix in sRGB space to match Three.js fog rendering
vec3 finalColor = mix(fogColor, skyColor.rgb, fogFactor);
vec3 finalColor = mix(skyColor.rgb, fogColor, fogFactor);
gl_FragColor = vec4(finalColor, 1.0);
}
`,
@ -131,7 +165,7 @@ export function SkyBox({
}
return (
<mesh scale={5000}>
<mesh scale={5000} frustumCulled={false}>
<sphereGeometry args={[1, 60, 40]} />
<primitive ref={materialRef} object={shaderMaterial} attach="material" />
</mesh>
@ -141,7 +175,7 @@ export function SkyBox({
export function Sky({ object }: { object: TorqueObject }) {
const { fogEnabled } = useSettings();
// Skybox textures.
// Skybox textures
const materialList = getProperty(object, "materialList");
const skySolidColor = useMemo(() => {
@ -162,9 +196,22 @@ export function Sky({ object }: { object: TorqueObject }) {
const useSkyTextures = getInt(object, "useSkyTextures") ?? 1;
// Fog parameters.
// TODO: There can be multiple fog volumes/layers. Render simple fog for now.
const fogDistance = getFloat(object, "fogDistance");
// Fog parameters - Tribes 2 uses fogDistance (near) and visibleDistance (far)
// high_* variants are used for high quality settings (-1 or 0 means use normal)
const fogDistanceBase = getFloat(object, "fogDistance");
const visibleDistanceBase = getFloat(object, "visibleDistance");
const highFogDistance = getFloat(object, "high_fogDistance");
const highVisibleDistance = getFloat(object, "high_visibleDistance");
// Use high quality values if available and valid (> 0)
const fogNear =
highFogDistance != null && highFogDistance > 0
? highFogDistance
: fogDistanceBase;
const fogFar =
highVisibleDistance != null && highVisibleDistance > 0
? highVisibleDistance
: visibleDistanceBase;
const fogColor = useMemo(() => {
const colorString = getProperty(object, "fogColor");
@ -188,15 +235,18 @@ export function Sky({ object }: { object: TorqueObject }) {
<color attach="background" args={[skyColor[0]]} />
) : null;
// Only enable fog if we have valid near/far distances
const hasFogParams = fogNear != null && fogFar != null && fogFar > fogNear;
return (
<>
{materialList && useSkyTextures ? (
// Load the DML for skybox textures
<Suspense fallback={backgroundColor}>
<SkyBox
materialList={materialList}
fogColor={fogEnabled ? fogColor?.[1] : undefined}
fogDistance={fogEnabled ? fogDistance : undefined}
fogColor={fogEnabled && hasFogParams ? fogColor?.[1] : undefined}
fogNear={fogEnabled && hasFogParams ? fogNear : undefined}
fogFar={fogEnabled && hasFogParams ? fogFar : undefined}
/>
</Suspense>
) : (
@ -208,13 +258,8 @@ export function Sky({ object }: { object: TorqueObject }) {
<Suspense>
<CloudLayers object={object} />
</Suspense>
{fogEnabled && fogDistance && fogColor ? (
<fog
attach="fog"
color={fogColor[1]}
near={100}
far={Math.max(400, fogDistance * 2)}
/>
{fogEnabled && hasFogParams && fogColor ? (
<fog attach="fog" color={fogColor[1]} near={fogNear!} far={fogFar!} />
) : null}
</>
);

View file

@ -1,36 +1,28 @@
import { memo, Suspense, useCallback, useMemo } from "react";
import { memo, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { useQuery } from "@tanstack/react-query";
import {
DataTexture,
RedFormat,
FloatType,
NoColorSpace,
NearestFilter,
NoColorSpace,
ClampToEdgeWrapping,
UnsignedByteType,
PlaneGeometry,
DoubleSide,
FrontSide,
RedFormat,
RepeatWrapping,
UnsignedByteType,
} from "three";
import { useTexture } from "@react-three/drei";
import { uint16ToFloat32 } from "../arrayUtils";
import { loadTerrain, terrainTextureToUrl } from "../loaders";
import type { TorqueObject } from "../torqueScript";
import {
getInt,
getPosition,
getProperty,
getRotation,
getScale,
} from "../mission";
import {
setupColor,
setupMask,
updateTerrainTextureShader,
} from "../textureUtils";
import { useDebug } from "./SettingsProvider";
import { getFloat, getInt, getPosition, getProperty } from "../mission";
import { loadTerrain } from "../loaders";
import { uint16ToFloat32 } from "../arrayUtils";
import { setupMask } from "../textureUtils";
import { TerrainTile } from "./TerrainTile";
import { useSceneObject } from "./useSceneObject";
const DEFAULT_SQUARE_SIZE = 8;
const DEFAULT_VISIBLE_DISTANCE = 600;
const TERRAIN_SIZE = 256;
/**
* Load a .ter file, used for terrain heightmap and texture info.
@ -42,164 +34,60 @@ function useTerrain(terrainFile: string) {
});
}
function BlendedTerrainTextures({
displacementMap,
visibilityMask,
textureNames,
alphaMaps,
}: {
displacementMap: DataTexture;
visibilityMask: DataTexture;
textureNames: string[];
alphaMaps: Uint8Array[];
}) {
const { debugMode } = useDebug();
const baseTextures = useTexture(
textureNames.map((name) => terrainTextureToUrl(name)),
(textures) => {
textures.forEach((tex) => setupColor(tex));
},
);
const alphaTextures = useMemo(
() => alphaMaps.map((data) => setupMask(data)),
[alphaMaps],
);
const tiling = useMemo(
() => ({
0: 32,
1: 32,
2: 32,
3: 32,
4: 32,
5: 32,
}),
[],
);
const onBeforeCompile = useCallback(
(shader) => {
updateTerrainTextureShader({
shader,
baseTextures,
alphaTextures,
visibilityMask,
tiling,
debugMode,
});
},
[baseTextures, alphaTextures, visibilityMask, tiling, debugMode],
);
return (
<meshStandardMaterial
// For testing tiling values; forces recompile.
key={`${JSON.stringify(tiling)}-${debugMode}`}
displacementMap={displacementMap}
map={displacementMap}
displacementScale={2048}
depthWrite
// In debug mode, render both sides so we can see wireframe from below
side={debugMode ? DoubleSide : FrontSide}
onBeforeCompile={onBeforeCompile}
/>
);
/**
* Get visibleDistance from the Sky object, used to determine how far terrain
* tiles should render. This matches Tribes 2's terrain tiling behavior.
*/
function useVisibleDistance(): number {
const sky = useSceneObject("Sky");
if (!sky) return DEFAULT_VISIBLE_DISTANCE;
const highVisibleDistance = getFloat(sky, "high_visibleDistance");
if (highVisibleDistance != null && highVisibleDistance > 0) {
return highVisibleDistance;
}
return getFloat(sky, "visibleDistance") ?? DEFAULT_VISIBLE_DISTANCE;
}
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]);
interface TileAssignment {
tileX: number;
tileZ: number;
}
const visibilityMask: DataTexture | null = useMemo(() => {
if (!emptySquares.length) {
return null;
}
/**
* Create a visibility mask texture from emptySquares data.
*/
function createVisibilityMask(emptySquares: number[]): DataTexture {
const maskData = new Uint8Array(TERRAIN_SIZE * TERRAIN_SIZE);
maskData.fill(255); // Start with everything visible
const terrainSize = 256;
for (const squareId of emptySquares) {
const x = squareId & 0xff;
const y = (squareId >> 8) & 0xff;
const count = squareId >> 16;
const rowOffset = y * TERRAIN_SIZE;
// 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;
}
for (let i = 0; i < count; i++) {
const index = rowOffset + x + i;
if (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>
const texture = new DataTexture(
maskData,
TERRAIN_SIZE,
TERRAIN_SIZE,
RedFormat,
UnsignedByteType,
);
texture.colorSpace = NoColorSpace;
texture.wrapS = texture.wrapT = ClampToEdgeWrapping;
texture.magFilter = NearestFilter;
texture.minFilter = NearestFilter;
texture.needsUpdate = true;
return texture;
}
export const TerrainBlock = memo(function TerrainBlock({
@ -209,53 +97,163 @@ export const TerrainBlock = memo(function TerrainBlock({
}) {
const terrainFile = getProperty(object, "terrainFile");
const squareSize = getInt(object, "squareSize") ?? DEFAULT_SQUARE_SIZE;
const blockSize = squareSize * 256;
const visibleDistance = useVisibleDistance();
const camera = useThree((state) => state.camera);
const emptySquares: number[] = useMemo(() => {
const emptySquaresValue = getProperty(object, "emptySquares");
// Note: This is a space-separated string, so we split and parse each component.
return emptySquaresValue
? emptySquaresValue.split(" ").map((s: string) => parseInt(s, 10))
: [];
const basePosition = useMemo(() => {
const [x, , z] = getPosition(object);
return { x, z };
}, [object]);
const position = useMemo(() => {
// Terrain position.z is ignored in Torque - heightmap values are absolute
const [x, y, z] = getPosition(object);
return [x, 0, z] as [number, number, number];
const emptySquares = useMemo(() => {
const value = getProperty(object, "emptySquares");
return value ? value.split(" ").map((s: string) => parseInt(s, 10)) : [];
}, [object]);
const q = useMemo(() => getRotation(object), [object]);
const scale = useMemo(() => getScale(object), [object]);
const planeGeometry = useMemo(() => {
// Shared geometry for all tiles
const sharedGeometry = useMemo(() => {
const size = squareSize * 256;
const geometry = new PlaneGeometry(size, size, 256, 256);
// PlaneGeometry starts in XY plane. Rotate to XZ plane for Y-up world.
geometry.rotateX(-Math.PI / 2);
// Also need to rotate to swap X and Z.
geometry.rotateY(-Math.PI / 2);
// Shift origin from center to corner so position offset works correctly.
// Tribes 2 terrain origin is at the corner, Three.js PlaneGeometry is centered.
// But, T2 does this before the `squareSize` scales it up or down, so it's
// essentially a fixed offset.
const defaultSize = DEFAULT_SQUARE_SIZE * 256;
geometry.translate(defaultSize / 2, 0, defaultSize / 2);
return geometry;
}, [squareSize]);
const { data: terrain } = useTerrain(terrainFile);
// Shared displacement map from heightmap - created once for all tiles
const sharedDisplacementMap = useMemo(() => {
if (!terrain) return null;
const f32HeightMap = uint16ToFloat32(terrain.heightMap);
const texture = new DataTexture(
f32HeightMap,
TERRAIN_SIZE,
TERRAIN_SIZE,
RedFormat,
FloatType,
);
texture.colorSpace = NoColorSpace;
texture.generateMipmaps = false;
texture.wrapS = RepeatWrapping;
texture.wrapT = RepeatWrapping;
texture.needsUpdate = true;
return texture;
}, [terrain]);
// Visibility mask for primary tile (0,0) - may have empty squares
const primaryVisibilityMask = useMemo(
() => createVisibilityMask(emptySquares),
[emptySquares],
);
// Visibility mask for pooled tiles - all visible (no empty squares)
// This is a stable reference shared by all pooled tiles
const pooledVisibilityMask = useMemo(() => createVisibilityMask([]), []);
// Shared alpha textures from terrain alphaMaps - created once for all tiles
const sharedAlphaTextures = useMemo(() => {
if (!terrain) return null;
return terrain.alphaMaps.map((data) => setupMask(data));
}, [terrain]);
// Calculate the maximum number of tiles that can be visible at once.
const poolSize = useMemo(() => {
const extent = Math.ceil(visibleDistance / blockSize);
const gridSize = 2 * extent + 1;
return gridSize * gridSize - 1; // -1 because primary tile is separate
}, [visibleDistance, blockSize]);
// Create stable pool indices for React keys
const poolIndices = useMemo(
() => Array.from({ length: poolSize }, (_, i) => i),
[poolSize],
);
// Track which tile coordinate each pool slot is assigned to
const [tileAssignments, setTileAssignments] = useState<
(TileAssignment | null)[]
>(() => Array(poolSize).fill(null));
// Track previous tile bounds to avoid unnecessary state updates
const prevBoundsRef = useRef({ xStart: 0, xEnd: 0, zStart: 0, zEnd: 0 });
useFrame(() => {
const relativeCamX = camera.position.x - basePosition.x;
const relativeCamZ = camera.position.z - basePosition.z;
const xStart = Math.floor((relativeCamX - visibleDistance) / blockSize);
const xEnd = Math.ceil((relativeCamX + visibleDistance) / blockSize);
const zStart = Math.floor((relativeCamZ - visibleDistance) / blockSize);
const zEnd = Math.ceil((relativeCamZ + visibleDistance) / blockSize);
// Early exit if bounds haven't changed
const prev = prevBoundsRef.current;
if (
xStart === prev.xStart &&
xEnd === prev.xEnd &&
zStart === prev.zStart &&
zEnd === prev.zEnd
) {
return;
}
prev.xStart = xStart;
prev.xEnd = xEnd;
prev.zStart = zStart;
prev.zEnd = zEnd;
// Build new assignments array
const newAssignments: (TileAssignment | null)[] = [];
for (let x = xStart; x < xEnd; x++) {
for (let z = zStart; z < zEnd; z++) {
if (x === 0 && z === 0) continue;
newAssignments.push({ tileX: x, tileZ: z });
}
}
while (newAssignments.length < poolSize) {
newAssignments.push(null);
}
setTileAssignments(newAssignments);
});
if (!terrain || !sharedDisplacementMap || !sharedAlphaTextures) {
return null;
}
return (
<group position={position} quaternion={q} scale={scale}>
<mesh geometry={planeGeometry} receiveShadow castShadow>
{terrain ? (
<TerrainMaterial
heightMap={terrain.heightMap}
emptySquares={emptySquares}
<>
{/* Primary tile at (0,0) with emptySquares applied */}
<TerrainTile
tileX={0}
tileZ={0}
blockSize={blockSize}
basePosition={basePosition}
textureNames={terrain.textureNames}
geometry={sharedGeometry}
displacementMap={sharedDisplacementMap}
visibilityMask={primaryVisibilityMask}
alphaTextures={sharedAlphaTextures}
/>
{/* Pooled tiles - stable keys, always mounted */}
{poolIndices.map((poolIndex) => {
const assignment = tileAssignments[poolIndex];
return (
<TerrainTile
key={poolIndex}
tileX={assignment?.tileX ?? 0}
tileZ={assignment?.tileZ ?? 0}
blockSize={blockSize}
basePosition={basePosition}
textureNames={terrain.textureNames}
alphaMaps={terrain.alphaMaps}
geometry={sharedGeometry}
displacementMap={sharedDisplacementMap}
visibilityMask={pooledVisibilityMask}
alphaTextures={sharedAlphaTextures}
visible={assignment !== null}
/>
) : null}
</mesh>
</group>
);
})}
</>
);
});

View file

@ -0,0 +1,151 @@
import { memo, Suspense, useCallback, useMemo } from "react";
import { DataTexture, DoubleSide, FrontSide, type PlaneGeometry } from "three";
import { useTexture } from "@react-three/drei";
import { terrainTextureToUrl } from "../loaders";
import { setupColor, updateTerrainTextureShader } from "../textureUtils";
import { useDebug } from "./SettingsProvider";
const DEFAULT_SQUARE_SIZE = 8;
// Texture tiling factors for each terrain layer
const TILING: Record<number, number> = {
0: 32,
1: 32,
2: 32,
3: 32,
4: 32,
5: 32,
};
interface TerrainTileProps {
tileX: number;
tileZ: number;
blockSize: number;
basePosition: { x: number; z: number };
textureNames: string[];
geometry: PlaneGeometry;
displacementMap: DataTexture;
visibilityMask: DataTexture;
alphaTextures: DataTexture[];
visible?: boolean;
}
function BlendedTerrainTextures({
displacementMap,
visibilityMask,
textureNames,
alphaTextures,
}: {
displacementMap: DataTexture;
visibilityMask: DataTexture;
textureNames: string[];
alphaTextures: DataTexture[];
}) {
const { debugMode } = useDebug();
const baseTextures = useTexture(
textureNames.map((name) => terrainTextureToUrl(name)),
(textures) => {
textures.forEach((tex) => setupColor(tex));
},
);
const onBeforeCompile = useCallback(
(shader) => {
updateTerrainTextureShader({
shader,
baseTextures,
alphaTextures,
visibilityMask,
tiling: TILING,
debugMode,
});
},
[baseTextures, alphaTextures, visibilityMask, debugMode],
);
return (
<meshStandardMaterial
key={debugMode ? "debug" : "normal"}
displacementMap={displacementMap}
map={displacementMap}
displacementScale={2048}
depthWrite
side={debugMode ? DoubleSide : FrontSide}
onBeforeCompile={onBeforeCompile}
/>
);
}
function TerrainMaterial({
displacementMap,
visibilityMask,
textureNames,
alphaTextures,
}: {
displacementMap: DataTexture;
visibilityMask: DataTexture;
textureNames: string[];
alphaTextures: DataTexture[];
}) {
return (
<Suspense
fallback={
<meshStandardMaterial
color="rgb(0, 109, 56)"
displacementMap={displacementMap}
displacementScale={2048}
wireframe
/>
}
>
<BlendedTerrainTextures
displacementMap={displacementMap}
visibilityMask={visibilityMask}
textureNames={textureNames}
alphaTextures={alphaTextures}
/>
</Suspense>
);
}
export const TerrainTile = memo(function TerrainTile({
tileX,
tileZ,
blockSize,
basePosition,
textureNames,
geometry,
displacementMap,
visibilityMask,
alphaTextures,
visible = true,
}: TerrainTileProps) {
const position = useMemo(() => {
// PlaneGeometry is centered at origin, but Tribes 2 terrain origin is at
// corner. The engine always uses the default square size (8) for positioning.
const geometryOffset = (DEFAULT_SQUARE_SIZE * 256) / 2;
return [
basePosition.x + tileX * blockSize + geometryOffset,
0,
basePosition.z + tileZ * blockSize + geometryOffset,
] as [number, number, number];
}, [tileX, tileZ, blockSize, basePosition]);
return (
<mesh
position={position}
geometry={geometry}
receiveShadow
castShadow
visible={visible}
>
<TerrainMaterial
displacementMap={displacementMap}
visibilityMask={visibilityMask}
textureNames={textureNames}
alphaTextures={alphaTextures}
/>
</mesh>
);
});

View file

@ -0,0 +1,14 @@
import type { TorqueObject } from "../torqueScript";
import { useRuntime } from "./RuntimeProvider";
/**
* Look up a scene object by name from the runtime.
*
* FIXME: This is not currently reactive! If the object is created after
* this hook runs, it won't be found. We'd need to add an event/subscription
* system to the runtime that fires when objects are created.
*/
export function useSceneObject(name: string): TorqueObject | undefined {
const runtime = useRuntime();
return runtime.getObjectByName(name);
}