fix terrain positioning, layer blending, lighting when sun points up, add xPack2

This commit is contained in:
Brian Beck 2025-12-12 14:16:21 -08:00
parent aeda3ca8d5
commit 7f75ed84da
2238 changed files with 159619 additions and 1107 deletions

View file

@ -11,7 +11,7 @@ import {
Texture,
BufferGeometry,
} from "three";
import { setupColor, setupAlphaTestedTexture } from "../textureUtils";
import { setupTexture } from "../textureUtils";
import { useDebug } from "./SettingsProvider";
import { useShapeInfo, isOrganicShape } from "./ShapeInfoProvider";
import { FloatingLabel } from "./FloatingLabel";
@ -217,10 +217,10 @@ const StaticTexture = memo(function StaticTexture({
const texture = useTexture(url, (texture) => {
// Organic/alpha-tested textures need special handling to avoid mipmap artifacts
if (isOrganic || isTranslucent) {
return setupAlphaTestedTexture(texture);
return setupTexture(texture, { disableMipmaps: true });
}
// Standard color texture setup for diffuse-only materials
return setupColor(texture);
return setupTexture(texture);
});
const customMaterial = useMemo(

View file

@ -13,7 +13,7 @@ import { useGLTF, useTexture } from "@react-three/drei";
import { textureToUrl, interiorToUrl } from "../loaders";
import type { TorqueObject } from "../torqueScript";
import { getPosition, getProperty, getRotation, getScale } from "../mission";
import { setupColor } from "../textureUtils";
import { setupTexture } from "../textureUtils";
import { FloatingLabel } from "./FloatingLabel";
import { useDebug } from "./SettingsProvider";
import { injectCustomFog } from "../fogShader";
@ -40,7 +40,7 @@ function InteriorTexture({
const debugContext = useDebug();
const debugMode = debugContext?.debugMode ?? false;
const url = textureToUrl(materialName);
const texture = useTexture(url, (texture) => setupColor(texture));
const texture = useTexture(url, (texture) => setupTexture(texture));
// Check for self-illuminating flag in material userData
// Note: The io_dif Blender add-on needs to be updated to export material flags
@ -95,7 +95,6 @@ function InteriorTexture({
key={materialKey}
map={texture}
toneMapped={false}
// @ts-expect-error - defines exists on Material but R3F types don't expose it
defines={defines}
onBeforeCompile={onBeforeCompile}
/>
@ -114,9 +113,8 @@ function InteriorTexture({
ref={lambertMaterialRef}
key={materialKey}
map={texture}
lightMap={lightMap ?? undefined}
lightMap={lightMap}
toneMapped={false}
// @ts-expect-error - defines exists on Material but R3F types don't expose it
defines={defines}
onBeforeCompile={onBeforeCompile}
/>

View file

@ -39,8 +39,10 @@ const sourceGroupNames: Record<string, string> = {
"z_mappacks/CTF/TWL2-MapPack.vl2": "TWL2",
"z_mappacks/CTF/TWL2-MapPackEDIT.vl2": "TWL2",
"z_mappacks/TWL_T2arenaOfficialMaps.vl2": "Arena",
"z_mappacks/xPack2.vl2": "xPack2",
"z_mappacks/z_DMP2-V0.6.vl2": "DMP2 (Discord Map Pack)",
"z_mappacks/zDMP-4.7.3DX.vl2": "DMP (Discord Map Pack)",
"z_mappacks/zDMP-4.7.3DX-ServerOnly.vl2": "DMP (Discord Map Pack)",
};
const dirGroupNames: Record<string, string> = {

View file

@ -1,7 +1,8 @@
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { Color, Vector3 } from "three";
import type { TorqueObject } from "../torqueScript";
import { getProperty } from "../mission";
import { updateGlobalSunUniforms } from "../globalSunUniforms";
export function Sun({ object }: { object: TorqueObject }) {
// Parse sun direction - points FROM sun TO scene
@ -43,6 +44,16 @@ export function Sun({ object }: { object: TorqueObject }) {
return new Color(r, g, b);
}, [object]);
// Torque lighting check (terrLighting.cc): if light direction points up,
// terrain surfaces with upward normals receive only ambient light.
// direction.y < 0 means light pointing down (toward ground)
const sunLightPointsDown = direction.y < 0;
// Update global uniform so terrain shader knows the light direction
useEffect(() => {
updateGlobalSunUniforms(sunLightPointsDown);
}, [sunLightPointsDown]);
// Base lighting intensities - neutral baseline, each object type applies its own multipliers
// See lightingConfig.ts for per-object-type adjustments
const directionalIntensity = 1.0;
@ -69,6 +80,7 @@ export function Sun({ object }: { object: TorqueObject }) {
shadow-camera-far={12000}
shadow-bias={-0.00001}
shadow-normalBias={0.4}
shadow-radius={2}
/>
{/* Ambient fill light - prevents pure black shadows */}
<ambientLight color={ambient} intensity={ambientIntensity} />

View file

@ -17,7 +17,7 @@ import {
Vector3,
} from "three";
import type { TorqueObject } from "../torqueScript";
import { getFloat, getInt, getPosition, getProperty } from "../mission";
import { getFloat, getInt, getProperty } from "../mission";
import { loadTerrain } from "../loaders";
import { uint16ToFloat32 } from "../arrayUtils";
import { setupMask } from "../textureUtils";
@ -543,10 +543,13 @@ export const TerrainBlock = memo(function TerrainBlock({
const visibleDistance = useVisibleDistance();
const camera = useThree((state) => state.camera);
// Torque ignores the mission's terrain position and always uses a fixed formula:
// setPosition(Point3F(-squareSize * (BlockSize >> 1), -squareSize * (BlockSize >> 1), 0));
// where BlockSize = 256. See tribes2-engine/terrain/terrData.cc:679
const basePosition = useMemo(() => {
const [x, , z] = getPosition(object);
return { x, z };
}, [object]);
const offset = -squareSize * (TERRAIN_SIZE / 2);
return { x: offset, z: offset };
}, [squareSize]);
const emptySquares = useMemo(() => {
const value = getProperty(object, "emptySquares");

View file

@ -11,14 +11,12 @@ import {
terrainTextureToUrl,
textureToUrl,
} from "../loaders";
import { setupColor } from "../textureUtils";
import { setupTexture } from "../textureUtils";
import { updateTerrainTextureShader } from "../terrainMaterial";
import { useDebug } from "./SettingsProvider";
import { injectCustomFog } from "../fogShader";
import { globalFogUniforms } from "../globalFogUniforms";
const DEFAULT_SQUARE_SIZE = 8;
// Texture tiling factors for each terrain layer
const TILING: Record<number, number> = {
0: 32,
@ -64,7 +62,7 @@ function BlendedTerrainTextures({
const baseTextures = useTexture(
textureNames.map((name) => terrainTextureToUrl(name)),
(textures) => {
textures.forEach((tex) => setupColor(tex));
textures.forEach((tex) => setupTexture(tex));
},
);
@ -76,7 +74,7 @@ function BlendedTerrainTextures({
const detailTexture = useTexture(
detailTextureUrl ?? FALLBACK_TEXTURE_URL,
(tex) => {
setupColor(tex);
setupTexture(tex);
},
);
@ -134,7 +132,6 @@ function BlendedTerrainTextures({
map={displacementMap}
depthWrite
side={FrontSide}
// @ts-expect-error - defines exists on Material but R3F types don't expose it
defines={{ DEBUG_MODE: debugMode ? 1 : 0 }}
onBeforeCompile={onBeforeCompile}
/>
@ -190,9 +187,10 @@ export const TerrainTile = memo(function TerrainTile({
visible = true,
}: TerrainTileProps) {
const position = useMemo(() => {
// Terrain geometry is centered at origin, but Tribes 2 terrain origin is at
// corner. The engine always uses the default square size (8) for positioning.
const geometryOffset = (DEFAULT_SQUARE_SIZE * 256) / 2;
// Terrain geometry is centered at origin. Torque's terrain position formula
// is -squareSize * 128, which equals -blockSize / 2. Since our geometry is
// already centered, basePosition + geometryOffset cancels to 0 for single tiles.
const geometryOffset = blockSize / 2;
return [
basePosition.x + tileX * blockSize + geometryOffset,
0,

View file

@ -11,7 +11,7 @@ import {
getRotation,
getScale,
} from "../mission";
import { setupColor } from "../textureUtils";
import { setupTexture } from "../textureUtils";
import { createWaterMaterial } from "../waterMaterial";
import { useDebug, useSettings } from "./SettingsProvider";
import { usePositionTracker } from "./usePositionTracker";
@ -63,7 +63,7 @@ export function WaterMaterial({
attach?: string;
}) {
const url = textureToUrl(surfaceTexture);
const texture = useTexture(url, (texture) => setupColor(texture));
const texture = useTexture(url, (texture) => setupTexture(texture));
return (
<meshStandardMaterial
@ -309,7 +309,7 @@ const WaterReps = memo(function WaterReps({
(textures) => {
const texArray = Array.isArray(textures) ? textures : [textures];
texArray.forEach((tex) => {
setupColor(tex);
setupTexture(tex);
tex.colorSpace = NoColorSpace;
tex.wrapS = RepeatWrapping;
tex.wrapT = RepeatWrapping;

View file

@ -29,8 +29,9 @@ interface IflAtlas {
const atlasCache = new Map<string, IflAtlas>();
function createAtlas(textures: Texture[]): IflAtlas {
const frameWidth = textures[0].image.width;
const frameHeight = textures[0].image.height;
const firstImage = textures[0].image as HTMLImageElement;
const frameWidth = firstImage.width;
const frameHeight = firstImage.height;
const frameCount = textures.length;
// Arrange frames in a roughly square grid.
@ -45,7 +46,7 @@ function createAtlas(textures: Texture[]): IflAtlas {
textures.forEach((tex, i) => {
const col = i % columns;
const row = Math.floor(i / columns);
ctx.drawImage(tex.image, col * frameWidth, row * frameHeight);
ctx.drawImage(tex.image as CanvasImageSource, col * frameWidth, row * frameHeight);
});
const texture = new CanvasTexture(canvas);

18
src/globalSunUniforms.ts Normal file
View file

@ -0,0 +1,18 @@
/**
* Global sun shader uniforms shared across all materials.
*
* Used to communicate sun light direction to shaders that need to adjust
* their lighting calculations (e.g., terrain ignores light from below).
*/
export const globalSunUniforms = {
/** True if sun light points downward (negative Y). False if pointing up. */
sunLightPointsDown: { value: true },
};
/**
* Update sun light direction state. Called by Sun component when direction changes.
*/
export function updateGlobalSunUniforms(sunLightPointsDown: boolean): void {
globalSunUniforms.sunLightPointsDown.value = sunLightPointsDown;
}

View file

@ -3,6 +3,7 @@
*/
import {
DoubleSide,
MeshStandardMaterial,
Texture,
RepeatWrapping,
@ -53,7 +54,7 @@ const alphaAsRoughnessShaderModifier = (shader: any) => {
#include <roughness_fragment>
// Override roughness with map alpha channel if map exists
#ifdef USE_MAP
roughnessFactor = texture2D(map, vMapUv).a * 1;
roughnessFactor = texture2D(map, vMapUv).a;
#endif
`,
);
@ -81,7 +82,7 @@ export function setupAlphaAsRoughnessTexture(texture: Texture) {
*/
export function createAlphaAsRoughnessMaterial() {
const material = new MeshStandardMaterial({
side: 2, // DoubleSide
side: DoubleSide,
metalness: 0.0,
roughness: 1.0,
});

View file

@ -17,6 +17,8 @@
* 4. Final output = lightmap × texture, all in gamma space
*/
import { globalSunUniforms } from "./globalSunUniforms";
// Terrain and texture dimensions (must match TerrainBlock.tsx constants)
const TERRAIN_SIZE = 256; // Terrain grid size in squares
const LIGHTMAP_SIZE = 512; // Lightmap texture size (2 pixels per terrain square)
@ -68,16 +70,17 @@ export function updateTerrainTextureShader({
detailTexture?: any;
lightmap?: any;
}) {
// Add global sun uniform (shared reference - value updates automatically)
shader.uniforms.sunLightPointsDown = globalSunUniforms.sunLightPointsDown;
const layerCount = baseTextures.length;
baseTextures.forEach((tex, i) => {
shader.uniforms[`albedo${i}`] = { value: tex };
});
// Pass all alpha textures including mask0 for additive blending
alphaTextures.forEach((tex, i) => {
if (i > 0) {
shader.uniforms[`mask${i}`] = { value: tex };
}
shader.uniforms[`mask${i}`] = { value: tex };
});
// Add visibility mask uniform if we have empty squares
@ -92,7 +95,6 @@ export function updateTerrainTextureShader({
};
});
// Add lightmap uniform for smooth per-pixel terrain lighting
if (lightmap) {
shader.uniforms.terrainLightmap = { value: lightmap };
@ -126,6 +128,7 @@ uniform sampler2D albedo2;
uniform sampler2D albedo3;
uniform sampler2D albedo4;
uniform sampler2D albedo5;
uniform sampler2D mask0;
uniform sampler2D mask1;
uniform sampler2D mask2;
uniform sampler2D mask3;
@ -139,6 +142,7 @@ uniform float tiling4;
uniform float tiling5;
${visibilityMask ? "uniform sampler2D visibilityMask;" : ""}
${lightmap ? "uniform sampler2D terrainLightmap;" : ""}
uniform bool sunLightPointsDown;
${
detailTexture
? `uniform sampler2D detailTexture;
@ -202,25 +206,29 @@ float terrainShadowFactor = 1.0;
: ""
}
// Sample linear masks (use R channel)
// Sample alpha masks for all layers (use R channel)
// Add +0.5 texel offset: Torque samples alpha at grid corners (integer indices),
// but GPU linear filtering samples at texel centers. This offset aligns them.
vec2 alphaUv = baseUv + vec2(0.5 / ${TERRAIN_SIZE}.0);
float a1 = texture2D(mask1, alphaUv).r;
${layerCount > 1 ? `float a2 = texture2D(mask2, alphaUv).r;` : ""}
${layerCount > 2 ? `float a3 = texture2D(mask3, alphaUv).r;` : ""}
${layerCount > 3 ? `float a4 = texture2D(mask4, alphaUv).r;` : ""}
${layerCount > 4 ? `float a5 = texture2D(mask5, alphaUv).r;` : ""}
float a0 = texture2D(mask0, alphaUv).r;
${layerCount > 1 ? `float a1 = texture2D(mask1, alphaUv).r;` : ""}
${layerCount > 2 ? `float a2 = texture2D(mask2, alphaUv).r;` : ""}
${layerCount > 3 ? `float a3 = texture2D(mask3, alphaUv).r;` : ""}
${layerCount > 4 ? `float a4 = texture2D(mask4, alphaUv).r;` : ""}
${layerCount > 5 ? `float a5 = texture2D(mask5, alphaUv).r;` : ""}
// Bottom-up compositing: each mask tells how much the higher layer replaces lower
${layerCount > 1 ? `vec3 blended = mix(c0, c1, clamp(a1, 0.0, 1.0));` : ""}
${layerCount > 2 ? `blended = mix(blended, c2, clamp(a2, 0.0, 1.0));` : ""}
${layerCount > 3 ? `blended = mix(blended, c3, clamp(a3, 0.0, 1.0));` : ""}
${layerCount > 4 ? `blended = mix(blended, c4, clamp(a4, 0.0, 1.0));` : ""}
${layerCount > 5 ? `blended = mix(blended, c5, clamp(a5, 0.0, 1.0));` : ""}
// Torque-style additive weighted blending (blender.cc):
// result = tex0 * alpha0 + tex1 * alpha1 + tex2 * alpha2 + ...
// Each layer's alpha map defines its contribution weight.
vec3 blended = c0 * a0;
${layerCount > 1 ? `blended += c1 * a1;` : ""}
${layerCount > 2 ? `blended += c2 * a2;` : ""}
${layerCount > 3 ? `blended += c3 * a3;` : ""}
${layerCount > 4 ? `blended += c4 * a4;` : ""}
${layerCount > 5 ? `blended += c5 * a5;` : ""}
// Assign to diffuseColor before lighting
vec3 textureColor = ${layerCount > 1 ? "blended" : "c0"};
vec3 textureColor = blended;
${
detailTexture
@ -256,6 +264,12 @@ float terrainShadowFactor = 1.0;
// Override RE_Direct to extract shadow factor for Torque-style gamma-space lighting
#undef RE_Direct
void RE_Direct_TerrainShadow( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in LambertMaterial material, inout ReflectedLight reflectedLight ) {
// Torque lighting (terrLighting.cc): if light points up, terrain gets only ambient
// This prevents shadow acne from light hitting terrain backfaces
if (!sunLightPointsDown) {
terrainShadowFactor = 0.0;
return;
}
// directLight.color = sunColor * shadowFactor (shadow already applied by Three.js)
// Extract shadow factor by comparing to original sun color
#if ( NUM_DIR_LIGHTS > 0 )

View file

@ -55,29 +55,6 @@ export function setupTexture<T extends Texture>(
return tex;
}
/**
* Setup a color texture with standard settings.
* @deprecated Use setupTexture() instead
*/
export function setupColor<T extends Texture>(
tex: T,
repeat: [number, number] = [1, 1],
): T {
return setupTexture(tex, { repeat });
}
/**
* Setup for alpha-tested textures (vegetation, etc).
* Disables mipmaps to prevent checkerboard artifacts from alpha averaging.
* @deprecated Use setupTexture(tex, { disableMipmaps: true }) instead
*/
export function setupAlphaTestedTexture<T extends Texture>(
tex: T,
repeat: [number, number] = [1, 1],
): T {
return setupTexture(tex, { repeat, disableMipmaps: true });
}
/**
* Setup a mask texture (single channel, linear color space).
* Used for terrain blend masks and similar data textures.