mirror of
https://github.com/exogen/t2-mapper.git
synced 2026-07-12 06:55:20 +00:00
fix mount rotations, add lights to flag and repair packs
This commit is contained in:
parent
76b2d11e14
commit
e7315c043a
146 changed files with 680 additions and 353 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
31
src/shaderUtils.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Shared GLSL utility functions for Torque-style gamma-space rendering.
|
||||
*
|
||||
* Used by terrain and interior materials which both need sRGB↔linear
|
||||
* 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);
|
||||
}
|
||||
`;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue