fix mount rotations, add lights to flag and repair packs

This commit is contained in:
Brian Beck 2026-04-08 23:00:53 -07:00
parent 76b2d11e14
commit e7315c043a
146 changed files with 680 additions and 353 deletions

View file

@ -5,6 +5,7 @@ import type {
ShapeEntity as ShapeEntityType,
} from "../state/gameEntityTypes";
import { ShapeRenderer, MountedShapeContent } from "./GenericShape";
import type { ShapeLightConfig } from "./GenericShape";
import { ShapeInfoProvider } from "./ShapeInfoProvider";
import type { StaticShapeType } from "./ShapeInfoProvider";
import { DebugSuspense } from "./DebugSuspense";
@ -186,6 +187,23 @@ function ShapeEntity({
return Object.keys(m).length > 0 ? m : undefined;
}, [objectMounts, entity.imageSlots, entity.id]);
const shapeLightConfig = useMemo((): ShapeLightConfig | undefined => {
if (!entity.lightType) return undefined;
return {
type: entity.lightType,
color: (entity.lightColor ?? [1, 1, 1, 1]) as [
number,
number,
number,
number,
],
time: entity.lightTime ?? 1000,
radius: entity.lightRadius ?? 10,
onlyStatic: !!entity.lightOnlyStatic,
isStatic: !!entity.isStaticItem,
};
}, [entity.lightType]);
return (
<ShapeInfoProvider
object={entity.runtimeObject as TorqueObject | undefined}
@ -200,6 +218,7 @@ function ShapeEntity({
entityId={entity.id}
skinName={entity.skinName}
mounted={allMounts}
lightConfig={shapeLightConfig}
>
{flagLabel ? (
<FloatingLabel opacity={0.6}>{flagLabel}</FloatingLabel>

View file

@ -170,6 +170,29 @@ function PositionedEntityWrapper({
return new Quaternion(...entity.rotation);
}, [entity.rotation]);
// Build object mount content for entities mounted on this one (e.g. players
// sitting in a vehicle). Each mounted entity renders via EntityRenderer
// inside the target's mount bone (portaled by ShapeRenderer).
// This must be above early returns to satisfy React's hooks rules.
const objectMounts = useMemo(() => {
if (!mountChildren || mountChildren.size === 0) return undefined;
const mounts: Record<number, React.ReactNode> = {};
for (const [node, child] of mountChildren) {
// Object mounts (players in vehicles) need a counter-rotation because
// the mounted PlayerModel applies its own R90 Y which conflicts with
// the vehicle's bone chain. Image mounts (MountedShapeContent) handle
// their own orientation via the Mountpoint inverse, so no extra rotation.
mounts[node] = (
<Suspense key={child.id}>
<group rotation={[Math.PI / 2, -Math.PI / 2, 0]}>
<EntityRenderer entity={child} />
</group>
</Suspense>
);
}
return mounts;
}, [mountChildren]);
// Entities without a resolved shape get a wireframe placeholder.
if (entity.renderType === "Shape" && !entity.shapeName) {
return (
@ -202,22 +225,6 @@ function PositionedEntityWrapper({
</mesh>
);
// Build object mount content for entities mounted on this one (e.g. players
// sitting in a vehicle). Each mounted entity renders via EntityRenderer
// inside the target's mount bone (portaled by ShapeRenderer).
const objectMounts = useMemo(() => {
if (!mountChildren || mountChildren.size === 0) return undefined;
const mounts: Record<number, React.ReactNode> = {};
for (const [node, child] of mountChildren) {
mounts[node] = (
<Suspense key={child.id}>
<EntityRenderer entity={child} />
</Suspense>
);
}
return mounts;
}, [mountChildren]);
return (
<group
name={entity.id}

View file

@ -15,12 +15,14 @@ import {
LoopOnce,
LoopRepeat,
NormalBlending,
Color,
Group,
Box3,
Vector3,
RepeatWrapping,
NoColorSpace,
} from "three";
import type { PointLight } from "three";
import * as SkeletonUtils from "three/examples/jsm/utils/SkeletonUtils.js";
import { useAnisotropy } from "./useAnisotropy";
import { useDebug, useSettings } from "./SettingsProvider";
@ -82,16 +84,15 @@ function advanceCloakUV(frameId: number): void {
getCloakTexture().offset.set(_cloakShiftX / 127, _cloakShiftY / 126);
}
// Counter-rotate for object-mounted content (players in vehicles).
// The mount bone is inside the vehicle's R90 Y group (DTS Z-up → Three.js
// Y-up). The mounted PlayerModel applies its own R90 Y. We need to undo the
// player's R90 and rotate from GLB Y-up back to DTS Z-up so the player
// stands upright relative to the vehicle's DTS bone coordinate system.
const MOUNT_COUNTER_ROTATION: [x: number, y: number, z: number] = [
Math.PI / 2,
-Math.PI / 2,
0,
];
/** Item/ShapeBase built-in light config from datablock. */
export interface ShapeLightConfig {
type: number;
color: [number, number, number, number];
time: number;
radius: number;
onlyStatic: boolean;
isStatic: boolean;
}
const STANDARD_90_ROTATION: [x: number, y: number, z: number] = [
0,
@ -116,6 +117,12 @@ interface StreamShapeEntity {
fadeVal?: number;
cloakLevel?: number;
dataBlockId?: number;
lightType?: number;
lightColor?: [number, number, number, number];
lightTime?: number;
lightRadius?: number;
lightOnlyStatic?: boolean;
isStaticItem?: boolean;
}
const log = createLogger("GenericShape");
@ -237,6 +244,7 @@ export const ShapeRenderer = memo(function ShapeRenderer({
mounted,
noRotation,
skinName,
lightConfig,
}: {
loadingColor?: string;
/** Stable entity reference whose fields are mutated in-place. */
@ -251,6 +259,8 @@ export const ShapeRenderer = memo(function ShapeRenderer({
noRotation?: boolean;
/** Skin texture URL (Torque reSkin: replaces "base." textures with this URL). */
skinName?: string;
/** Item/ShapeBase built-in light config (from datablock). */
lightConfig?: ShapeLightConfig;
}) {
const { shapeName } = useShapeInfo();
@ -275,6 +285,7 @@ export const ShapeRenderer = memo(function ShapeRenderer({
mounted={mounted}
noRotation={noRotation}
skinName={skinName}
lightConfig={lightConfig}
>
{children}
</ShapeModelLoader>
@ -295,6 +306,8 @@ interface VisNode {
interface ThreadState {
sequence: string;
action?: AnimationAction;
/** Morph target frame animation actions played alongside the main clip. */
morphActions?: AnimationAction[];
visNodes?: VisNode[];
startTime: number;
forward: boolean;
@ -315,6 +328,7 @@ export const ShapeModel = memo(function ShapeModel({
mounted,
noRotation,
skinName,
lightConfig: lightConfigProp,
}: {
gltf: ReturnType<typeof useStaticShape>;
/** Stable entity reference whose fields are mutated in-place. */
@ -329,6 +343,8 @@ export const ShapeModel = memo(function ShapeModel({
noRotation?: boolean;
/** Skin texture URL (Torque reSkin: replaces "base." textures). */
skinName?: string;
/** Item/ShapeBase built-in light config (from datablock). */
lightConfig?: ShapeLightConfig;
}) {
const { object, shapeName } = useShapeInfo();
const { debugMode } = useDebug();
@ -336,79 +352,94 @@ export const ShapeModel = memo(function ShapeModel({
const runtime = useEngineSelector((state) => state.runtime.runtime);
const anisotropy = useAnisotropy();
const { clonedScene, mixer, clipsByName, visNodesBySequence, iflMeshes } =
useMemo(() => {
const scene = SkeletonUtils.clone(gltf.scene) as Group;
const {
clonedScene,
mixer,
clipsByName,
morphClipsBySeq,
visNodesBySequence,
iflMeshes,
} = useMemo(() => {
const scene = SkeletonUtils.clone(gltf.scene) as Group;
// Detect IFL materials BEFORE processShapeScene replaces them, since the
// replacement materials lose the original userData (flag_names, resource_path).
const iflInfos: Array<{
mesh: any;
iflPath: string;
hasVisSequence: boolean;
repeat: boolean;
iflSequence?: string;
iflDuration?: number;
iflCyclic?: boolean;
iflToolBegin?: number;
}> = [];
scene.traverse((node: any) => {
if (!node.isMesh || !node.material) return;
const mat = Array.isArray(node.material)
? node.material[0]
: node.material;
if (!mat?.userData) return;
const flags = new Set<string>(mat.userData.flag_names ?? []);
const rp: string | undefined = mat.userData.resource_path;
if (flags.has("IflMaterial") && rp) {
const ud = node.userData;
// ifl_sequence is set by the addon when ifl_matters links this IFL to
// a controlling sequence. vis_sequence is a separate system (opacity
// animation) and must NOT be used as a fallback — the two are independent.
const iflSeq = ud?.ifl_sequence
? String(ud.ifl_sequence).toLowerCase()
: undefined;
const iflDur = ud?.ifl_duration ? Number(ud.ifl_duration) : undefined;
const iflCyclic = ud?.ifl_sequence ? !!ud.ifl_cyclic : undefined;
const iflToolBegin =
ud?.ifl_tool_begin != null ? Number(ud.ifl_tool_begin) : undefined;
iflInfos.push({
mesh: node,
iflPath: `textures/${rp}.ifl`,
hasVisSequence: !!ud?.vis_sequence,
repeat: flags.has("SWrap") || flags.has("TWrap"),
iflSequence: iflSeq,
iflDuration: iflDur,
iflCyclic,
iflToolBegin,
});
}
});
processShapeScene(scene, shapeName ?? undefined, {
anisotropy,
emap,
skinName,
});
// Un-hide IFL meshes that don't have a vis sequence — they should always
// be visible. IFL meshes WITH vis sequences stay hidden until their
// sequence is activated by playThread.
for (const { mesh, hasVisSequence } of iflInfos) {
if (!hasVisSequence) {
mesh.visible = true;
}
}
// Collect ALL vis-animated nodes, grouped by sequence name.
const visBySeq = new Map<string, VisNode[]>();
scene.traverse((node: any) => {
if (!node.isMesh) return;
// Detect IFL materials BEFORE processShapeScene replaces them, since the
// replacement materials lose the original userData (flag_names, resource_path).
const iflInfos: Array<{
mesh: any;
iflPath: string;
hasVisSequence: boolean;
repeat: boolean;
iflSequence?: string;
iflDuration?: number;
iflCyclic?: boolean;
iflToolBegin?: number;
}> = [];
scene.traverse((node: any) => {
if (!node.isMesh || !node.material) return;
const mat = Array.isArray(node.material)
? node.material[0]
: node.material;
if (!mat?.userData) return;
const flags = new Set<string>(mat.userData.flag_names ?? []);
const rp: string | undefined = mat.userData.resource_path;
if (flags.has("IflMaterial") && rp) {
const ud = node.userData;
if (!ud) return;
const kf = ud.vis_keyframes;
const dur = ud.vis_duration;
const seqName = (ud.vis_sequence ?? "").toLowerCase();
// ifl_sequence is set by the addon when ifl_matters links this IFL to
// a controlling sequence. vis_sequence is a separate system (opacity
// animation) and must NOT be used as a fallback — the two are independent.
const iflSeq = ud?.ifl_sequence
? String(ud.ifl_sequence).toLowerCase()
: undefined;
const iflDur = ud?.ifl_duration ? Number(ud.ifl_duration) : undefined;
const iflCyclic = ud?.ifl_sequence ? !!ud.ifl_cyclic : undefined;
const iflToolBegin =
ud?.ifl_tool_begin != null ? Number(ud.ifl_tool_begin) : undefined;
iflInfos.push({
mesh: node,
iflPath: `textures/${rp}.ifl`,
hasVisSequence: !!ud?.vis_sequence,
repeat: flags.has("SWrap") || flags.has("TWrap"),
iflSequence: iflSeq,
iflDuration: iflDur,
iflCyclic,
iflToolBegin,
});
}
});
processShapeScene(scene, shapeName ?? undefined, {
anisotropy,
emap,
skinName,
});
// Un-hide IFL meshes that don't have a vis sequence — they should always
// be visible. IFL meshes WITH vis sequences stay hidden until their
// sequence is activated by playThread.
for (const { mesh, hasVisSequence } of iflInfos) {
if (!hasVisSequence) {
mesh.visible = true;
}
}
// Collect ALL vis-animated nodes, grouped by sequence name.
// Multiple sequences can animate the same mesh (e.g. station_inv_human
// has Activate1 + Activate with vis data). The addon exports both the
// primary vis_keyframes/vis_sequence AND per-sequence suffixed versions
// like vis_keyframes_activate, vis_duration_activate.
const visBySeq = new Map<string, VisNode[]>();
scene.traverse((node: any) => {
if (!node.isMesh) return;
const ud = node.userData;
if (!ud) return;
// Helper: register one vis entry
const addVis = (
seqName: string,
kf: number[],
dur: number,
cyclic: boolean,
) => {
if (
!seqName ||
!Array.isArray(kf) ||
@ -417,63 +448,121 @@ export const ShapeModel = memo(function ShapeModel({
dur <= 0
)
return;
let list = visBySeq.get(seqName);
if (!list) {
list = [];
visBySeq.set(seqName, list);
}
list.push({
mesh: node,
keyframes: kf,
duration: dur,
cyclic: !!ud.vis_cyclic,
});
});
// Build clips by name (case-insensitive).
// Blend sequences (DTS flag 0x8) store absolute transforms but must be
// played in additive mode. Clone and convert them here so the original
// cached clips from useGLTF are never mutated.
const blendNames = new Set<string>();
const rawNames = scene.userData?.dts_sequence_names;
const rawBlend = scene.userData?.dts_sequence_blend;
if (typeof rawNames === "string") {
try {
const names: string[] = JSON.parse(rawNames);
const blend: boolean[] =
typeof rawBlend === "string" ? JSON.parse(rawBlend) : [];
for (let i = 0; i < names.length; i++) {
if (blend[i]) blendNames.add(names[i].toLowerCase());
}
} catch {
/* expected */
}
}
const clips = new Map<string, AnimationClip>();
for (const clip of gltf.animations) {
const lower = clip.name.toLowerCase();
if (blendNames.has(lower)) {
const cloned = clip.clone();
const restClip = buildRestPoseClip(scene, cloned);
AnimationUtils.makeClipAdditive(cloned, 0, restClip, 30);
clips.set(lower, cloned);
} else {
clips.set(lower, clip);
}
}
// Only create a mixer if there are skeleton animation clips.
const mix = clips.size > 0 ? new AnimationMixer(scene) : null;
return {
clonedScene: scene,
mixer: mix,
clipsByName: clips,
visNodesBySequence: visBySeq,
iflMeshes: iflInfos,
// Avoid duplicate mesh entries for the same sequence
if (list.some((v) => v.mesh === node)) return;
list.push({ mesh: node, keyframes: kf, duration: dur, cyclic });
};
}, [gltf.scene, gltf.animations, shapeName, anisotropy, emap, skinName]);
// Primary vis entry (backwards compatible)
addVis(
(ud.vis_sequence ?? "").toLowerCase(),
ud.vis_keyframes,
ud.vis_duration,
!!ud.vis_cyclic,
);
// Per-sequence suffixed entries (vis_keyframes_activate, etc.)
for (const key of Object.keys(ud)) {
const match = key.match(/^vis_keyframes_(.+)$/);
if (match) {
const suffix = match[1];
addVis(
suffix,
ud[`vis_keyframes_${suffix}`],
ud[`vis_duration_${suffix}`],
!!ud[`vis_cyclic_${suffix}`],
);
}
}
});
// Build clips by name (case-insensitive).
// Blend sequences (DTS flag 0x8) store absolute transforms but must be
// played in additive mode. Clone and convert them here so the original
// cached clips from useGLTF are never mutated.
const blendNames = new Set<string>();
const rawNames = scene.userData?.dts_sequence_names;
const rawBlend = scene.userData?.dts_sequence_blend;
if (typeof rawNames === "string") {
try {
const names: string[] = JSON.parse(rawNames);
const blend: boolean[] =
typeof rawBlend === "string" ? JSON.parse(rawBlend) : [];
for (let i = 0; i < names.length; i++) {
if (blend[i]) blendNames.add(names[i].toLowerCase());
}
} catch {
/* expected */
}
}
// Build a set of known sequence names (lowercase) from the DTS metadata
// so we can reliably identify morph target frame clips below.
const knownSeqNames = new Set<string>();
if (typeof rawNames === "string") {
try {
for (const n of JSON.parse(rawNames) as string[]) {
knownSeqNames.add(n.toLowerCase());
}
} catch {
/* expected */
}
}
const clips = new Map<string, AnimationClip>();
// Morph target frame animations are exported as separate clips named
// "{SeqName}_{MeshName}_frame". Collect them so they can be played
// alongside the main sequence clip.
const morphClipsBySeq = new Map<string, AnimationClip[]>();
for (const clip of gltf.animations) {
const lower = clip.name.toLowerCase();
// Check if this is a morph target frame clip by testing if it ends
// with "_frame" and starts with a known sequence name prefix.
if (lower.endsWith("_frame")) {
let matched = false;
for (const seqName of knownSeqNames) {
if (
lower.startsWith(seqName + "_") &&
lower.length > seqName.length + 1 + 5
) {
let list = morphClipsBySeq.get(seqName);
if (!list) {
list = [];
morphClipsBySeq.set(seqName, list);
}
list.push(clip);
matched = true;
break;
}
}
if (matched) continue;
}
if (blendNames.has(lower)) {
const cloned = clip.clone();
const restClip = buildRestPoseClip(scene, cloned);
AnimationUtils.makeClipAdditive(cloned, 0, restClip, 30);
clips.set(lower, cloned);
} else {
clips.set(lower, clip);
}
}
// Only create a mixer if there are skeleton animation clips.
const mix = clips.size > 0 ? new AnimationMixer(scene) : null;
return {
clonedScene: scene,
mixer: mix,
clipsByName: clips,
morphClipsBySeq,
visNodesBySequence: visBySeq,
iflMeshes: iflInfos,
};
}, [gltf.scene, gltf.animations, shapeName, anisotropy, emap, skinName]);
// Dispose cloned geometries and materials when the scene is replaced or
// the component unmounts, to prevent GPU memory from accumulating.
@ -511,7 +600,7 @@ export const ShapeModel = memo(function ShapeModel({
const streamEntityRef = useRef(streamEntity);
streamEntityRef.current = streamEntity;
const handlePlayThreadRef = useRef<
((slot: number, seq: string) => void) | null
((slot: number, seq: string, forward?: boolean) => void) | null
>(null);
const handleStopThreadRef = useRef<((slot: number) => void) | null>(null);
const prevDemoThreadsRef = useRef<StreamThreadState[] | undefined>(undefined);
@ -594,8 +683,14 @@ export const ShapeModel = memo(function ShapeModel({
v.mesh.material = result.material;
}
if (v.mesh.material && !Array.isArray(v.mesh.material)) {
v.mesh.material.transparent = true;
v.mesh.material.depthWrite = false;
// Save original transparent/depthWrite so they can be restored
// when the vis animation finishes or is stopped.
const ud = (v.mesh.material.userData ??= {});
if (ud._visOrigTransparent == null) {
ud._visOrigTransparent = v.mesh.material.transparent;
ud._visOrigDepthWrite = v.mesh.material.depthWrite;
ud._visOrigAlphaTest = v.mesh.material.alphaTest;
}
}
const atlas = iflMeshAtlasRef.current.get(v.mesh);
if (atlas && v.mesh.material && !Array.isArray(v.mesh.material)) {
@ -645,6 +740,22 @@ export const ShapeModel = memo(function ShapeModel({
}
action.play();
thread.action = action;
// Play associated morph target frame clips alongside the main clip.
const morphClips = morphClipsBySeq.get(seqLower);
if (morphClips) {
thread.morphActions = [];
for (const mc of morphClips) {
const ma = mixer.clipAction(mc);
ma.setLoop(cyclic ? LoopRepeat : LoopOnce, cyclic ? Infinity : 1);
if (!cyclic) ma.clampWhenFinished = true;
ma.timeScale = forward ? 1 : -1;
ma.reset();
if (!forward) ma.time = mc.duration;
ma.play();
thread.morphActions.push(ma);
}
}
}
if (vNodes) {
@ -659,12 +770,23 @@ export const ShapeModel = memo(function ShapeModel({
const thread = threads.get(slot);
if (!thread) return;
if (thread.action) thread.action.stop();
if (thread.morphActions) {
for (const ma of thread.morphActions) ma.stop();
}
// Binary Stop: reset position to 0.0, freeze. Vis nodes go to frame 0.
// Restore original material properties saved by prepareVisNode.
if (thread.visNodes) {
for (const v of thread.visNodes) {
if (v.mesh.material && !Array.isArray(v.mesh.material)) {
v.mesh.material.opacity = v.keyframes[0];
const mat = v.mesh.material;
mat.opacity = v.keyframes[0];
v.mesh.visible = v.keyframes[0] > 0.01;
const ud = mat.userData;
if (ud?._visOrigTransparent != null) {
mat.transparent = ud._visOrigTransparent;
mat.depthWrite = ud._visOrigDepthWrite;
mat.alphaTest = ud._visOrigAlphaTest;
}
}
}
}
@ -719,7 +841,7 @@ export const ShapeModel = memo(function ShapeModel({
wheelAnimsRef.current = null;
}
// ── Demo/live mode: no auto-play, useFrame drives from ghost data ──
// ── Demo/live mode: ghost thread handler in useFrame drives everything ──
if (!isMissionMode) {
return () => {
handlePlayThreadRef.current = null;
@ -761,6 +883,9 @@ export const ShapeModel = memo(function ShapeModel({
const thread = threads.get(Number(slot));
if (thread?.action) {
thread.action.paused = true;
if (thread.morphActions) {
for (const ma of thread.morphActions) ma.paused = true;
}
}
},
),
@ -769,12 +894,20 @@ export const ShapeModel = memo(function ShapeModel({
// Start default looping sequences immediately. Thread slots match
// power.cs globals: $PowerThread=0, $AmbientThread=1.
// In Torque, these are ghosted script threads started by server scripts,
// but the non-script ambient thread (ShapeBaseImageData.ambientSequence)
// is client-only and not ghosted. Auto-play both here as they're
// typically active on spawned shapes.
const defaults: Array<[number, string]> = [
[0, "power"],
[1, "ambient"],
];
for (const [slot, seqName] of defaults) {
if (clipsByName.has(seqName) || visNodesBySequence.has(seqName)) {
if (
clipsByName.has(seqName) ||
visNodesBySequence.has(seqName) ||
morphClipsBySeq.has(seqName)
) {
handlePlayThread(slot, seqName);
}
}
@ -874,6 +1007,9 @@ export const ShapeModel = memo(function ShapeModel({
const thread = threads.get(slot);
if (thread?.action) {
thread.action.paused = true;
if (thread.morphActions) {
for (const ma of thread.morphActions) ma.paused = true;
}
}
} else {
// Play (state === 0)
@ -892,15 +1028,23 @@ export const ShapeModel = memo(function ShapeModel({
thread.action.setLoop(LoopOnce, 1);
thread.action.clampWhenFinished = true;
thread.action.paused = true;
if (thread.morphActions) {
for (const ma of thread.morphActions) {
const mc = ma.getClip();
ma.time = t.forward ? mc.duration : 0;
ma.timeScale = 1;
ma.setLoop(LoopOnce, 1);
ma.clampWhenFinished = true;
ma.paused = true;
}
}
}
// Snap vis nodes to end pose.
if (thread?.visNodes) {
for (const v of thread.visNodes) {
const mat = v.mesh.material;
if (!mat || Array.isArray(mat)) continue;
const endIdx = t.forward
? v.keyframes.length - 1
: 0;
const endIdx = t.forward ? v.keyframes.length - 1 : 0;
mat.opacity = v.keyframes[endIdx];
v.mesh.visible = mat.opacity > 0.01;
}
@ -919,6 +1063,12 @@ export const ShapeModel = memo(function ShapeModel({
// Resume from pause with correct direction.
thread.action.paused = false;
thread.action.timeScale = t.forward ? 1 : -1;
if (thread.morphActions) {
for (const ma of thread.morphActions) {
ma.paused = false;
ma.timeScale = t.forward ? 1 : -1;
}
}
}
}
}
@ -936,7 +1086,7 @@ export const ShapeModel = memo(function ShapeModel({
// Drive vis opacity animations for active threads.
// Direction is handled by computing position forward or backward.
for (const [slot, thread] of threads) {
for (const [, thread] of threads) {
if (!thread.visNodes) continue;
for (const { mesh, keyframes, duration, cyclic } of thread.visNodes) {
@ -953,7 +1103,7 @@ export const ShapeModel = memo(function ShapeModel({
let t: number;
if (cyclic) {
// Cyclic: wrap position, ignoring direction (cyclic always advances).
t = ((elapsed % duration) + duration) % duration / duration;
t = (((elapsed % duration) + duration) % duration) / duration;
} else if (thread.forward) {
t = Math.min(elapsed / duration, 1);
} else {
@ -968,6 +1118,27 @@ export const ShapeModel = memo(function ShapeModel({
const frac = pos - lo;
mat.opacity = keyframes[lo] + (keyframes[hi] - keyframes[lo]) * frac;
mesh.visible = mat.opacity > 0.01;
// Dynamically toggle transparent/depthWrite: only enable blending
// when partially transparent, restore originals at full opacity.
const ud = mat.userData;
// Toggle transparent/depthWrite based on current opacity.
// needsUpdate is required when changing transparent — Three.js
// uses different render lists for opaque vs transparent objects.
if (mat.opacity >= 0.99) {
if (ud?._visOrigTransparent != null) {
if (mat.transparent !== ud._visOrigTransparent) {
mat.transparent = ud._visOrigTransparent;
mat.needsUpdate = true;
}
mat.depthWrite = ud._visOrigDepthWrite;
mat.alphaTest = ud._visOrigAlphaTest;
}
} else if (!mat.transparent) {
mat.transparent = true;
mat.depthWrite = false;
mat.alphaTest = 0;
mat.needsUpdate = true;
}
}
}
@ -1221,9 +1392,69 @@ export const ShapeModel = memo(function ShapeModel({
eyePos.set(gz, gy, -gx);
});
// Item/ShapeBase built-in dynamic light (binary-verified pulsing formula).
// Torque places a GL point light at getBoxCenter() with:
// constant_attenuation = 0, linear_attenuation = 1/radius, quadratic = 0
// This gives attenuation = radius/distance — surfaces at zero distance get
// infinite brightness, producing the characteristic "opaque glow" where the
// item's own mesh is massively overlit and goes pure white at peak pulse.
const lightRef = useRef<PointLight>(null);
const lightConfig = useMemo(() => {
const cfg = lightConfigProp;
if (!cfg) return null;
const box = new Box3().setFromObject(gltf.scene);
const center = new Vector3();
box.getCenter(center);
return {
type: cfg.type,
color: new Color(cfg.color[0], cfg.color[1], cfg.color[2]),
time: cfg.time,
radius: cfg.radius,
onlyStatic: cfg.onlyStatic,
isStatic: cfg.isStatic,
center: [center.x, center.y, center.z] as [number, number, number],
};
}, [gltf.scene, lightConfigProp]);
useFrame(() => {
if (!lightRef.current || !lightConfig) return;
if (lightConfig.onlyStatic && !lightConfig.isStatic) {
lightRef.current.intensity = 0;
return;
}
const fadeVal = streamEntityRef.current?.fadeVal ?? 1;
const elapsed = shapeNowSec() * 1000; // ms
let intensity: number;
if (lightConfig.type === 2) {
// PulsingLight (binary-verified): sin(PI * t / lightTime), period = 2 * lightTime
const sinVal = Math.sin((Math.PI * elapsed) / lightConfig.time);
const raw = 0.5 + 0.5 * sinVal;
intensity = (0.15 + raw * 0.85) * fadeVal;
} else {
// ConstantLight
intensity = fadeVal;
}
// Torque uses GL attenuation = radius/d (linear, constant=0). With decay=1
// in Three.js, the falloff is ~1/d within the distance window. Scale by
// radius² to approximate Torque's overbright behavior — surfaces near the
// light center get extremely bright (flag mesh goes pure white at peak pulse).
lightRef.current.intensity =
intensity * lightConfig.radius * lightConfig.radius;
});
return (
<group rotation={noRotation ? undefined : STANDARD_90_ROTATION}>
<primitive object={clonedScene} />
{lightConfig && (
<pointLight
ref={lightRef}
color={lightConfig.color}
position={lightConfig.center}
intensity={0}
distance={lightConfig.radius * 2}
decay={1}
/>
)}
{debugMode ? (
<FloatingLabel>
{entityId}: {shapeName}
@ -1241,10 +1472,7 @@ export const ShapeModel = memo(function ShapeModel({
const bone = mountBones[Number(slot)];
return bone ? (
<Fragment key={slot}>
{createPortal(
<group rotation={MOUNT_COUNTER_ROTATION}>{content}</group>,
bone,
)}
{createPortal(<group>{content}</group>, bone)}
</Fragment>
) : null;
})}
@ -1260,6 +1488,7 @@ function ShapeModelLoader({
mounted,
noRotation,
skinName,
lightConfig,
}: {
streamEntity?: StreamShapeEntity;
emap?: boolean;
@ -1268,6 +1497,7 @@ function ShapeModelLoader({
mounted?: Record<number, ReactNode>;
noRotation?: boolean;
skinName?: string;
lightConfig?: ShapeLightConfig;
}) {
const { shapeName } = useShapeInfo();
const gltf = useStaticShape(shapeName);
@ -1280,6 +1510,7 @@ function ShapeModelLoader({
mounted={mounted}
noRotation={noRotation}
skinName={skinName}
lightConfig={lightConfig}
>
{children}
</ShapeModel>

View file

@ -42,39 +42,33 @@ function mutateRenderFields(
renderEntity: GameEntity,
stream: StreamEntity,
): void {
// Fields common to all positioned entities.
// Shared fields (on PositionedBase, used by both Player and Shape).
const e = renderEntity as unknown as Record<string, unknown>;
e.mountObjectId = stream.mountObjectId;
e.mountNode = stream.mountNode;
e.imageSlots = stream.imageSlots;
e.threads = stream.threads;
e.armAction = stream.armAction;
e.targetRenderFlags = stream.targetRenderFlags;
e.iffColor = stream.iffColor;
e.soundSlots = stream.soundSlots;
// Type-specific fields.
switch (renderEntity.renderType) {
case "Player": {
e.threads = stream.threads;
e.armAction = stream.armAction;
case "Player":
e.falling = stream.falling;
e.jetting = stream.jetting;
e.weaponImageState = stream.weaponImageState;
e.weaponImageStates = stream.weaponImageStates;
e.playerName = stream.playerName;
e.iffColor = stream.iffColor;
e.headPitch = stream.headPitch;
e.headYaw = stream.headYaw;
e.targetRenderFlags = stream.targetRenderFlags;
e.soundSlots = stream.soundSlots;
break;
}
case "Shape": {
e.threads = stream.threads;
case "Shape":
e.damageState = stream.damageState;
e.fadeVal = stream.fadeVal;
e.cloakLevel = stream.cloakLevel;
e.armAction = stream.armAction;
e.targetRenderFlags = stream.targetRenderFlags;
e.iffColor = stream.iffColor;
e.soundSlots = stream.soundSlots;
break;
}
}
}

View file

@ -1,4 +1,5 @@
import { Vector3 } from "three";
import { glslColorSpace, glslDebugGrid } from "./shaderUtils";
/**
* Interior material shader modifications for MeshLambertMaterial.
@ -30,30 +31,6 @@ export type InteriorLightingOptions = {
surfaceOutsideVisible?: boolean;
};
// sRGB <-> Linear conversion functions (GLSL)
const colorSpaceFunctions = /* glsl */ `
vec3 interiorLinearToSRGB(vec3 linear) {
vec3 higher = pow(linear, vec3(1.0/2.4)) * 1.055 - 0.055;
vec3 lower = linear * 12.92;
return mix(lower, higher, step(vec3(0.0031308), linear));
}
vec3 interiorSRGBToLinear(vec3 srgb) {
vec3 higher = pow((srgb + 0.055) / 1.055, vec3(2.4));
vec3 lower = srgb / 12.92;
return mix(lower, higher, step(vec3(0.04045), srgb));
}
// Debug grid overlay function using screen-space derivatives for sharp, anti-aliased lines
// Returns 1.0 on grid lines, 0.0 elsewhere
float debugGrid(vec2 uv, float gridSize, float lineWidth) {
vec2 scaledUV = uv * gridSize;
vec2 grid = abs(fract(scaledUV - 0.5) - 0.5) / fwidth(scaledUV);
float line = min(grid.x, grid.y);
return 1.0 - min(line / lineWidth, 1.0);
}
`;
export function injectInteriorLighting(
shader: any,
options: InteriorLightingOptions,
@ -76,7 +53,8 @@ export function injectInteriorLighting(
shader.fragmentShader = shader.fragmentShader.replace(
"#include <common>",
`#include <common>
${colorSpaceFunctions}
${glslColorSpace}
${glslDebugGrid}
uniform bool useSceneLighting;
uniform vec3 interiorDebugColor;
`,
@ -98,7 +76,11 @@ uniform vec3 interiorDebugColor;
"#include <opaque_fragment>",
`// Torque-style lighting: output = clamp(lighting × texture, 0, 1) in sRGB space
// Get texture in sRGB space (undo Three.js linear decode)
vec3 textureSRGB = interiorLinearToSRGB(diffuseColor.rgb);
vec3 textureSRGB = torqueLinearToSRGB(diffuseColor.rgb);
// Save Three.js computed direct lighting (includes sun + point/spot lights).
// We'll add it back for point/spot light contribution after our gamma-space calc.
vec3 interiorAllLightsLinear = reflectedLight.directDiffuse;
// Compute lighting in sRGB space
vec3 lightingSRGB = vec3(0.0);
@ -122,7 +104,7 @@ if (useSceneLighting) {
// (stored in .ml files). Inside surfaces only have base lightmap. Both need lightmap here.
#ifdef USE_LIGHTMAP
// Lightmap is stored as linear in Three.js (decoded from sRGB texture), convert back
lightingSRGB += interiorLinearToSRGB(lightMapTexel.rgb);
lightingSRGB += torqueLinearToSRGB(lightMapTexel.rgb);
#endif
// Torque clamps the sum to [0,1] per channel (sceneLighting.cc lines 1817-1827)
lightingSRGB = clamp(lightingSRGB, 0.0, 1.0);
@ -131,10 +113,14 @@ lightingSRGB = clamp(lightingSRGB, 0.0, 1.0);
vec3 resultSRGB = clamp(lightingSRGB * textureSRGB, 0.0, 1.0);
// Convert back to linear for Three.js output pipeline
vec3 resultLinear = interiorSRGBToLinear(resultSRGB);
vec3 resultLinear = torqueSRGBToLinear(resultSRGB);
// Reassign outgoingLight before opaque_fragment consumes it
// Add dynamic point/spot lights when present (avoid sun double-count otherwise)
outgoingLight = resultLinear + totalEmissiveRadiance;
#if ( NUM_POINT_LIGHTS > 0 || NUM_SPOT_LIGHTS > 0 )
outgoingLight += interiorAllLightsLinear;
#endif
#include <opaque_fragment>`,
);
@ -148,7 +134,7 @@ outgoingLight = resultLinear + totalEmissiveRadiance;
// Red grid = inside surface (no scene ambient light)
#if DEBUG_MODE && defined(USE_MAP)
// gridSize=4 creates 4x4 grid per UV tile, lineWidth=1.5 is ~1.5 pixels wide
float gridIntensity = debugGrid(vMapUv, 4.0, 1.5);
float gridIntensity = torqueDebugGrid(vMapUv, 4.0, 1.5);
gl_FragColor.rgb = mix(gl_FragColor.rgb, interiorDebugColor, gridIntensity * 0.1);
#endif

31
src/shaderUtils.ts Normal file
View file

@ -0,0 +1,31 @@
/**
* Shared GLSL utility functions for Torque-style gamma-space rendering.
*
* Used by terrain and interior materials which both need sRGBlinear
* conversion and debug grid overlays.
*/
/** sRGB ↔ Linear conversion functions. */
export const glslColorSpace = /* glsl */ `
vec3 torqueLinearToSRGB(vec3 linear) {
vec3 higher = pow(linear, vec3(1.0/2.4)) * 1.055 - 0.055;
vec3 lower = linear * 12.92;
return mix(lower, higher, step(vec3(0.0031308), linear));
}
vec3 torqueSRGBToLinear(vec3 srgb) {
vec3 higher = pow((srgb + 0.055) / 1.055, vec3(2.4));
vec3 lower = srgb / 12.92;
return mix(lower, higher, step(vec3(0.04045), srgb));
}
`;
/** Debug grid overlay using screen-space derivatives. */
export const glslDebugGrid = /* glsl */ `
float torqueDebugGrid(vec2 uv, float gridSize, float lineWidth) {
vec2 scaledUV = uv * gridSize;
vec2 grid = abs(fract(scaledUV - 0.5) - 0.5) / fwidth(scaledUV);
float line = min(grid.x, grid.y);
return 1.0 - min(line / lineWidth, 1.0);
}
`;

View file

@ -122,8 +122,25 @@ interface PositionedBase extends EntityBase {
scale?: [number, number, number];
velocity?: [number, number, number];
keyframes?: Keyframe[];
// ── Shared gameplay fields (used by both Shape and Player) ──
dataBlock?: string;
skinName?: string;
/** Mounted image slots (0-7). Mount bone from dataBlock->mountPoint. */
imageSlots?: (ImageSlot | undefined)[];
threads?: ThreadState[];
/** Arm blend animation action index from Player ghost (networked). */
armAction?: number;
/** Torque DamageState: 0=Enabled, 1=Disabled, 2=Destroyed. */
damageState?: number;
targetRenderFlags?: number;
iffColor?: { r: number; g: number; b: number };
/** ShapeBase sound slots (from ghost SoundMask). */
soundSlots?: Array<{ index: number; playing: boolean; profileId?: number }>;
health?: number;
energy?: number;
actionAnim?: number;
actionAtEnd?: boolean;
}
// ── Gameplay entities ──
@ -133,18 +150,8 @@ export interface ShapeEntity extends PositionedBase {
renderType: "Shape";
shapeName?: string;
shapeType?: string;
dataBlock?: string;
/** Skin name from TargetManager (e.g. "beagle" for flags, team skins). */
skinName?: string;
threads?: ThreadState[];
/** Torque DamageState: 0=Enabled, 1=Disabled, 2=Destroyed. */
damageState?: number;
rotate?: boolean;
teamId?: number;
targetRenderFlags?: number;
iffColor?: { r: number; g: number; b: number };
/** Arm blend animation action index from Player ghost (networked). */
armAction?: number;
/** WheeledVehicle per-wheel state (speed, slip). */
wheels?: Array<{
speed: number;
@ -157,41 +164,31 @@ export interface ShapeEntity extends PositionedBase {
frozen?: boolean;
/** Vehicle max steering angle (radians), from datablock. */
maxSteeringAngle?: number;
/** ShapeBase sound slots (from ghost SoundMask). */
soundSlots?: Array<{ index: number; playing: boolean; profileId?: number }>;
/** ShapeBase fade value (0=invisible, 1=fully visible). Matches mFadeVal. */
fadeVal?: number;
/** Cloak level (0=visible, 1=fully cloaked). Used for cloak texture effect. */
cloakLevel?: number;
/** Item/ShapeBase built-in dynamic light from datablock. */
lightType?: number;
lightColor?: [number, number, number, number];
lightTime?: number;
lightRadius?: number;
lightOnlyStatic?: boolean;
isStaticItem?: boolean;
}
export interface PlayerEntity extends PositionedBase {
renderType: "Player";
shapeName?: string;
dataBlock?: string;
/** Arm blend animation action index from Player ghost (networked). */
armAction?: number;
/** Player preferred skin (chosen skin like "RandySavage"). */
skinPrefName?: string;
falling?: boolean;
jetting?: boolean;
playerName?: string;
/** Player skin (team skin like "base", "baseb"). */
skinName?: string;
/** Player preferred skin (chosen skin like "RandySavage"). */
skinPrefName?: string;
iffColor?: { r: number; g: number; b: number };
threads?: ThreadState[];
weaponImageState?: WeaponImageState;
weaponImageStates?: WeaponImageDataBlockState[];
headPitch?: number;
headYaw?: number;
health?: number;
energy?: number;
actionAnim?: number;
actionAtEnd?: boolean;
damageState?: number;
targetRenderFlags?: number;
/** ShapeBase sound slots (from ghost SoundMask). */
soundSlots?: Array<{ index: number; playing: boolean; profileId?: number }>;
}
export interface ForceFieldBareEntity extends PositionedBase {

View file

@ -147,6 +147,12 @@ export interface MutableEntity {
/** Item mStatic flag (from InitialUpdateMask). Static items (flags at
* flagstand) skip all physics in Item::processTick. */
isStaticItem?: boolean;
/** Item/ShapeBase built-in dynamic light from datablock. */
lightType?: number;
lightColor?: [number, number, number, number];
lightTime?: number;
lightRadius?: number;
lightOnlyStatic?: boolean;
/** Item velocity interpolation state. The client simulates full physics
* (gravity, collision, bounce) for non-static, non-at-rest items. */
@ -1040,6 +1046,20 @@ export abstract class StreamEngine implements StreamingPlayback {
entity.maxEnergy = blockData.maxEnergy;
}
// Item/ShapeBase built-in dynamic light (binary-verified).
const lt = blockData?.lightType as number | undefined;
if (lt && lt > 0 && blockData) {
entity.lightType = lt;
const lc = blockData.lightColor as
| { r: number; g: number; b: number; a?: number }
| undefined;
entity.lightColor = lc ? [lc.r, lc.g, lc.b, lc.a ?? 1] : [1, 1, 1, 1];
entity.lightTime = (blockData.lightTime as number | undefined) ?? 1000;
entity.lightRadius =
(blockData.lightRadius as number | undefined) ?? 10;
entity.lightOnlyStatic = !!(blockData.lightOnlyStatic as boolean);
}
// Classify projectile physics
if (entity.type === "Projectile") {
if (linearProjectileClassNames.has(entity.className)) {
@ -2464,6 +2484,12 @@ export abstract class StreamEngine implements StreamingPlayback {
shapeHint: entity.shapeHint,
dataBlock: entity.dataBlock,
imageSlots: entity.imageSlots,
lightType: entity.lightType,
lightColor: entity.lightColor,
lightTime: entity.lightTime,
lightRadius: entity.lightRadius,
lightOnlyStatic: entity.lightOnlyStatic,
isStaticItem: entity.isStaticItem,
mountObjectId:
entity.mountObjectGhostIndex != null
? this.entityIdByGhostIndex.get(entity.mountObjectGhostIndex)

View file

@ -22,6 +22,8 @@ function positionedBase(entity: StreamEntity, spawnTime?: number) {
ghostIndex: entity.ghostIndex,
dataBlockId: entity.dataBlockId,
shapeHint: entity.shapeHint,
dataBlock: entity.dataBlock,
skinName: entity.skinName,
spawnTime,
position: entity.position,
rotation: entity.rotation,
@ -29,6 +31,16 @@ function positionedBase(entity: StreamEntity, spawnTime?: number) {
mountObjectId: entity.mountObjectId,
mountNode: entity.mountNode,
imageSlots: entity.imageSlots,
threads: entity.threads,
armAction: entity.armAction,
damageState: entity.damageState,
targetRenderFlags: entity.targetRenderFlags,
iffColor: entity.iffColor,
soundSlots: entity.soundSlots,
health: entity.health,
energy: entity.energy,
actionAnim: entity.actionAnim,
actionAtEnd: entity.actionAtEnd,
keyframes: [
{
time: spawnTime ?? 0,
@ -120,21 +132,14 @@ export function streamEntityToGameEntity(
...positionedBase(entity, spawnTime),
renderType: "Player",
shapeName: entity.dataBlock,
dataBlock: entity.dataBlock,
armAction: entity.armAction,
skinPrefName: entity.skinPrefName,
falling: entity.falling,
jetting: entity.jetting,
playerName: entity.playerName,
skinName: entity.skinName,
skinPrefName: entity.skinPrefName,
iffColor: entity.iffColor,
threads: entity.threads,
weaponImageState: entity.weaponImageState,
weaponImageStates: entity.weaponImageStates,
headPitch: entity.headPitch,
headYaw: entity.headYaw,
targetRenderFlags: entity.targetRenderFlags,
soundSlots: entity.soundSlots,
} satisfies PlayerEntity;
case "Explosion":
@ -231,20 +236,18 @@ export function streamEntityToGameEntity(
: entity.className === "Item"
? "Item"
: "StaticShape",
dataBlock: entity.dataBlock,
skinName: entity.skinName,
damageState: entity.damageState,
armAction: entity.armAction,
threads: entity.threads,
targetRenderFlags: entity.targetRenderFlags,
iffColor: entity.iffColor,
wheels: entity.wheels,
steeringYaw: entity.steeringYaw,
frozen: entity.frozen,
maxSteeringAngle: entity.maxSteeringAngle,
soundSlots: entity.soundSlots,
fadeVal: entity.fadeVal,
cloakLevel: entity.cloakLevel,
lightType: entity.lightType,
lightColor: entity.lightColor,
lightTime: entity.lightTime,
lightRadius: entity.lightRadius,
lightOnlyStatic: entity.lightOnlyStatic,
isStaticItem: entity.isStaticItem,
} satisfies ShapeEntity;
}
}

View file

@ -225,6 +225,38 @@ function buildShapeEntity(
}
}
// Item/ShapeBase built-in dynamic light from datablock.
const lightTypeStr = getProperty(datablock, "lightType");
if (lightTypeStr) {
const ltMap: Record<string, number> = {
constantlight: 1,
pulsinglight: 2,
};
const lt = ltMap[lightTypeStr.toLowerCase()];
if (lt) {
entity.lightType = lt;
const lcStr = getProperty(datablock, "lightColor");
if (lcStr) {
const parts = lcStr.split(/\s+/).map(Number);
entity.lightColor = [
parts[0] ?? 1,
parts[1] ?? 1,
parts[2] ?? 1,
parts[3] ?? 1,
];
} else {
entity.lightColor = [1, 1, 1, 1];
}
entity.lightTime = Number(getProperty(datablock, "lightTime")) || 1000;
entity.lightRadius = Number(getProperty(datablock, "lightRadius")) || 10;
entity.lightOnlyStatic = isTruthy(
getProperty(datablock, "lightOnlyStatic"),
);
// In mission mode, statically placed items are always "static".
entity.isStaticItem = className === "Item";
}
}
if (className === "Turret") {
const barrelName = getProperty(object, "initialBarrel");
if (barrelName) {

View file

@ -110,6 +110,13 @@ export interface StreamEntity {
direction?: [number, number, number];
/** Mounted image slots (0-7). Mount bone from dataBlock->mountPoint. */
imageSlots?: (ImageSlot | undefined)[];
/** Item/ShapeBase built-in dynamic light from datablock. */
lightType?: number;
lightColor?: [number, number, number, number];
lightTime?: number;
lightRadius?: number;
lightOnlyStatic?: boolean;
isStaticItem?: boolean;
playerName?: string;
/** IFF color resolved from the sensor group color table (sRGB 0-255). */
iffColor?: { r: number; g: number; b: number };

View file

@ -18,6 +18,7 @@
*/
import { globalSunUniforms } from "./globalSunUniforms";
import { glslColorSpace, glslDebugGrid } from "./shaderUtils";
// Terrain and texture dimensions (must match TerrainBlock.tsx constants)
const TERRAIN_SIZE = 256; // Terrain grid size in squares
@ -29,30 +30,6 @@ const DETAIL_TILING = 64.0;
// Distance at which detail texture fully fades out (in world units)
const DETAIL_FADE_DISTANCE = 150.0;
// sRGB <-> Linear conversion functions (GLSL)
const colorSpaceFunctions = /* glsl */ `
vec3 terrainLinearToSRGB(vec3 linear) {
vec3 higher = pow(linear, vec3(1.0/2.4)) * 1.055 - 0.055;
vec3 lower = linear * 12.92;
return mix(lower, higher, step(vec3(0.0031308), linear));
}
vec3 terrainSRGBToLinear(vec3 srgb) {
vec3 higher = pow((srgb + 0.055) / 1.055, vec3(2.4));
vec3 lower = srgb / 12.92;
return mix(lower, higher, step(vec3(0.04045), srgb));
}
// Debug grid overlay using screen-space derivatives for sharp, anti-aliased lines
// Returns 1.0 on grid lines, 0.0 elsewhere
float terrainDebugGrid(vec2 uv, float gridSize, float lineWidth) {
vec2 scaledUV = uv * gridSize;
vec2 grid = abs(fract(scaledUV - 0.5) - 0.5) / fwidth(scaledUV);
float line = min(grid.x, grid.y);
return 1.0 - min(line / lineWidth, 1.0);
}
`;
export function updateTerrainTextureShader({
shader,
baseTextures,
@ -157,7 +134,8 @@ varying vec3 vTerrainWorldPos;`
: ""
}
${colorSpaceFunctions}
${glslColorSpace}
${glslDebugGrid}
// Global variable to store shadow factor from RE_Direct for use in output calculation
float terrainShadowFactor = 1.0;
@ -296,11 +274,12 @@ void RE_Direct_TerrainShadow( const in IncidentLight directLight, const in vec3
`,
);
// Override lights_fragment_begin to skip indirect diffuse calculation
// We'll handle ambient in gamma space
// Override lights_fragment_begin: save directDiffuse before lights run,
// then after lights_fragment_end we can extract the point/spot contribution.
shader.fragmentShader = shader.fragmentShader.replace(
"#include <lights_fragment_begin>",
`#include <lights_fragment_begin>
`vec3 terrainPreLightDirect = reflectedLight.directDiffuse;
#include <lights_fragment_begin>
// Clear indirect diffuse - we'll compute ambient in gamma space
#if defined( RE_IndirectDiffuse )
irradiance = vec3(0.0);
@ -308,11 +287,15 @@ void RE_Direct_TerrainShadow( const in IncidentLight directLight, const in vec3
`,
);
// Clear the indirect diffuse after lights_fragment_end
shader.fragmentShader = shader.fragmentShader.replace(
"#include <lights_fragment_end>",
`#include <lights_fragment_end>
// Clear Three.js lighting - we compute everything in gamma space
// Extract dynamic point/spot light contribution by subtracting what was
// there before lights ran. directDiffuse now has sun + point lights;
// terrainPreLightDirect was 0, so the difference is all lights.
// We'll subtract the sun part below and keep just the point/spot part.
vec3 terrainAllLightsLinear = reflectedLight.directDiffuse - terrainPreLightDirect;
// Clear Three.js lighting - we compute sun/ambient in gamma space
reflectedLight.directDiffuse = vec3(0.0);
reflectedLight.indirectDiffuse = vec3(0.0);
`,
@ -325,7 +308,7 @@ void RE_Direct_TerrainShadow( const in IncidentLight directLight, const in vec3
`// Torque-style terrain lighting: output = clamp(lighting × texture, 0, 1) in sRGB space
{
// Get texture in sRGB space (undo Three.js linear decode)
vec3 textureSRGB = terrainLinearToSRGB(diffuseColor.rgb);
vec3 textureSRGB = torqueLinearToSRGB(diffuseColor.rgb);
${
lightmap
@ -358,7 +341,16 @@ void RE_Direct_TerrainShadow( const in IncidentLight directLight, const in vec3
vec3 resultSRGB = clamp(lightingSRGB * textureSRGB, 0.0, 1.0);
// Convert back to linear for Three.js output pipeline
outgoingLight = terrainSRGBToLinear(resultSRGB) + totalEmissiveRadiance;
outgoingLight = torqueSRGBToLinear(resultSRGB) + totalEmissiveRadiance;
// Add dynamic point/spot light contributions when present.
// terrainAllLightsLinear includes both directional + point from Three.js.
// We only add it when point/spot lights exist to avoid double-counting
// the sun (already computed in gamma space above). The slight sun
// double-count when points are active is acceptable — point light
// intensity dominates near the source.
#if ( NUM_POINT_LIGHTS > 0 || NUM_SPOT_LIGHTS > 0 )
outgoingLight += terrainAllLightsLinear;
#endif
}
#include <opaque_fragment>`,
);
@ -369,7 +361,7 @@ void RE_Direct_TerrainShadow( const in IncidentLight directLight, const in vec3
"#include <tonemapping_fragment>",
`#if DEBUG_MODE
// Debug mode: overlay green grid matching terrain grid squares (256x256)
float gridIntensity = terrainDebugGrid(vTerrainUv, 256.0, 1.5);
float gridIntensity = torqueDebugGrid(vTerrainUv, 256.0, 1.5);
vec3 gridColor = vec3(0.0, 0.8, 0.4); // Green
gl_FragColor.rgb = mix(gl_FragColor.rgb, gridColor, gridIntensity * 0.1);
#endif