Fix texture orientation. Add AO. Fix fog transition in sky

This commit is contained in:
bmathews 2025-11-14 03:10:26 -08:00
parent 75750571de
commit 8ab6f63fee
10 changed files with 218 additions and 30 deletions

View file

@ -7,8 +7,8 @@ import {
getRotation,
getScale,
} from "@/src/mission";
import { memo, Suspense, useEffect, useMemo } from "react";
import { Material, Mesh, MeshBasicMaterial } from "three";
import { memo, Suspense, useMemo } from "react";
import { Mesh } from "three";
import { setupColor } from "@/src/textureUtils";
const FALLBACK_URL = `${BASE_URL}/black.png`;
@ -21,23 +21,17 @@ function useInterior(interiorFile: string) {
return useGLTF(url);
}
function InteriorTexture({ material }: { material: Material }) {
function InteriorTexture({ materialName }: { materialName: string }) {
let url = FALLBACK_URL;
try {
url = interiorTextureToUrl(material.name);
url = interiorTextureToUrl(materialName);
} catch (err) {
console.error(err);
}
const texture = useTexture(url, (texture) => setupColor(texture));
useEffect(() => {
const asBasicMaterial = material as MeshBasicMaterial;
asBasicMaterial.map = texture;
asBasicMaterial.needsUpdate = true;
}, [material, texture]);
return <primitive object={material} attach="material" />;
return <meshStandardMaterial map={texture} side={2} />;
}
function InteriorMesh({ node }: { node: Mesh }) {
@ -53,10 +47,10 @@ function InteriorMesh({ node }: { node: Mesh }) {
>
{Array.isArray(node.material) ? (
node.material.map((mat, index) => (
<InteriorTexture key={index} material={mat} />
<InteriorTexture key={index} materialName={mat.name} />
))
) : (
<InteriorTexture material={node.material} />
<InteriorTexture materialName={node.material.name} />
)}
</Suspense>
) : null}

View file

@ -16,12 +16,5 @@ export function Mission({ name }: { name: string }) {
return null;
}
return (
<>
<hemisphereLight
args={["rgba(209, 237, 255, 1)", "rgba(186, 200, 181, 1)", 2]}
/>
{mission.objects.map((object, i) => renderObject(object, i))}
</>
);
return <>{mission.objects.map((object, i) => renderObject(object, i))}</>;
}

View file

@ -1,10 +1,10 @@
import { ConsoleObject, getProperty } from "@/src/mission";
import { useSettings } from "./SettingsProvider";
import { Suspense, useMemo } from "react";
import { Suspense, useMemo, useEffect, useRef } from "react";
import { BASE_URL, getUrlForPath, loadDetailMapList } from "@/src/loaders";
import { useQuery } from "@tanstack/react-query";
import { useCubeTexture } from "@react-three/drei";
import { Color } from "three";
import { Color, ShaderMaterial, BackSide } from "three";
const FALLBACK_URL = `${BASE_URL}/black.png`;
@ -18,7 +18,15 @@ function useDetailMapList(name: string) {
});
}
export function SkyBox({ materialList }: { materialList: string }) {
export function SkyBox({
materialList,
fogColor,
fogDistance,
}: {
materialList: string;
fogColor?: Color;
fogDistance?: number;
}) {
const { data: detailMapList } = useDetailMapList(materialList);
const skyBoxFiles = useMemo(
@ -45,7 +53,83 @@ export function SkyBox({ materialList }: { materialList: string }) {
const skyBox = useCubeTexture(skyBoxFiles, { path: "" });
return <primitive attach="background" object={skyBox} />;
// Create a shader material for the skybox with fog
const materialRef = useRef<ShaderMaterial>(null!);
const hasFog = !!fogColor && !!fogDistance;
const shaderMaterial = useMemo(() => {
if (!hasFog) {
return null;
}
return new ShaderMaterial({
uniforms: {
skybox: { value: skyBox },
fogColor: { value: fogColor },
},
vertexShader: `
varying vec3 vDirection;
void main() {
// Use position directly as direction (no world transform needed)
vDirection = position;
// Transform position but ignore translation
vec4 pos = projectionMatrix * mat4(mat3(modelViewMatrix)) * vec4(position, 1.0);
gl_Position = pos.xyww; // Set depth to far plane
}
`,
fragmentShader: `
uniform samplerCube skybox;
uniform vec3 fogColor;
varying vec3 vDirection;
// Convert linear to sRGB
vec3 linearToSRGB(vec3 color) {
return pow(color, vec3(1.0 / 2.2));
}
void main() {
vec3 direction = normalize(vDirection);
direction.x = -direction.x;
vec4 skyColor = textureCube(skybox, direction);
// Calculate fog factor based on vertical direction
// direction.y: -1 = straight down, 0 = horizon, 1 = straight up
// 100% fog from bottom to horizon, then fade from horizon (0) to 0.4
float fogFactor = smoothstep(0.0, 0.4, direction.y);
// Mix in sRGB space to match Three.js fog rendering
vec3 finalColor = mix(fogColor, skyColor.rgb, fogFactor);
gl_FragColor = vec4(finalColor, 1.0);
}
`,
side: BackSide,
depthWrite: false,
});
}, [skyBox, fogColor, hasFog]);
// Update uniforms when fog parameters change
useEffect(() => {
if (materialRef.current && hasFog && shaderMaterial) {
materialRef.current.uniforms.skybox.value = skyBox;
materialRef.current.uniforms.fogColor.value = fogColor!;
}
}, [skyBox, fogColor, hasFog, shaderMaterial]);
// If fog is disabled, just use the skybox as background
if (!hasFog) {
return <primitive attach="background" object={skyBox} />;
}
return (
<mesh scale={5000}>
<sphereGeometry args={[1, 60, 40]} />
<primitive ref={materialRef} object={shaderMaterial} attach="material" />
</mesh>
);
}
export function Sky({ object }: { object: ConsoleObject }) {
@ -69,12 +153,15 @@ export function Sky({ object }: { object: ConsoleObject }) {
// `colorString` might specify an alpha value, but three.js doesn't
// support opacity on fog or scene backgrounds, so ignore it.
const [r, g, b] = colorString.split(" ").map((s) => parseFloat(s));
return new Color().setRGB(r, g, b);
return [
new Color().setRGB(r, g, b),
new Color().setRGB(r, g, b).convertSRGBToLinear(),
];
}
}, [object]);
const backgroundColor = fogColor ? (
<color attach="background" args={[fogColor]} />
<color attach="background" args={[fogColor[0]]} />
) : null;
return (
@ -83,14 +170,23 @@ export function Sky({ object }: { object: ConsoleObject }) {
// If there's a skybox, its textures will need to load. Render just the
// fog color as the background in the meantime.
<Suspense fallback={backgroundColor}>
<SkyBox materialList={materialList} />
<SkyBox
materialList={materialList}
fogColor={fogEnabled ? fogColor[1] : undefined}
fogDistance={fogEnabled ? fogDistance : undefined}
/>
</Suspense>
) : (
// If there's no skybox, just render the fog color as the background.
backgroundColor
)}
{fogEnabled && fogDistance && fogColor ? (
<fog attach="fog" color={fogColor} near={0} far={fogDistance * 2} />
<fog
attach="fog"
color={fogColor[1]}
near={fogDistance / 2}
far={fogDistance * 2}
/>
) : null}
</>
);

48
app/Sun.tsx Normal file
View file

@ -0,0 +1,48 @@
import { ConsoleObject, getProperty } from "@/src/mission";
import { useMemo } from "react";
import { Color } from "three";
export function Sun({ object }: { object: ConsoleObject }) {
const direction = useMemo(() => {
const directionStr = getProperty(object, "direction")?.value ?? "0 0 -1";
const [x, y, z] = directionStr.split(" ").map((s) => parseFloat(s));
// Scale the direction vector to position the light far from the scene
const scale = 5000;
return [x * scale, y * scale, z * scale] as [number, number, number];
}, [object]);
const color = useMemo(() => {
const colorStr = getProperty(object, "color")?.value ?? "1 1 1 1";
const [r, g, b] = colorStr.split(" ").map((s) => parseFloat(s));
return [r, g, b] as [number, number, number];
}, [object]);
const ambient = useMemo(() => {
const ambientStr = getProperty(object, "ambient")?.value ?? "0.5 0.5 0.5 1";
const [r, g, b] = ambientStr.split(" ").map((s) => parseFloat(s));
return [r, g, b] as [number, number, number];
}, [object]);
return (
<>
{/* Directional light for the sun */}
{/* <directionalLight
position={[500, 500, 500]}
target-position={direction}
color={color}
intensity={2}
castShadow
shadow-mapSize={[2048, 2048]}
shadow-camera-left={-2000}
shadow-camera-right={2000}
shadow-camera-top={2000}
shadow-camera-bottom={-2000}
shadow-camera-near={0.5}
shadow-camera-far={5000}
shadow-bias={-0.001}
/> */}
{/* Ambient light component */}
<hemisphereLight args={[new Color(...color), new Color(...ambient), 2]} />
</>
);
}

View file

@ -209,6 +209,8 @@ export function TerrainBlock({ object }: { object: ConsoleObject }) {
position={position}
scale={scale}
geometry={planeGeometry}
receiveShadow
castShadow
>
{terrain ? (
<TerrainMaterial

View file

@ -7,6 +7,7 @@ import { ObserverControls } from "./ObserverControls";
import { InspectorControls } from "./InspectorControls";
import { SettingsProvider } from "./SettingsProvider";
import { PerspectiveCamera } from "@react-three/drei";
import { EffectComposer, N8AO } from "@react-three/postprocessing";
// three.js has its own loaders for textures and models, but we need to load other
// stuff too, e.g. missions, terrains, and more. This client is used for those.
@ -20,7 +21,7 @@ export default function HomePage() {
<SettingsProvider fogEnabled={fogEnabled}>
<QueryClientProvider client={queryClient}>
<main>
<Canvas>
<Canvas shadows>
<ObserverControls />
<Mission key={missionName} name={missionName} />
<PerspectiveCamera
@ -28,6 +29,9 @@ export default function HomePage() {
position={[-512, 256, -512]}
fov={90}
/>
<EffectComposer>
<N8AO intensity={3} aoRadius={3} quality="performance" />
</EffectComposer>
</Canvas>
<InspectorControls
missionName={missionName}

View file

@ -4,6 +4,7 @@ import { WaterBlock } from "./WaterBlock";
import { SimGroup } from "./SimGroup";
import { InteriorInstance } from "./InteriorInstance";
import { Sky } from "./Sky";
import { Sun } from "./Sun";
const componentMap = {
SimGroup,
@ -11,6 +12,7 @@ const componentMap = {
WaterBlock,
InteriorInstance,
Sky,
Sun,
};
export function renderObject(object: ConsoleObject, key: string | number) {

47
package-lock.json generated
View file

@ -11,6 +11,7 @@
"dependencies": {
"@react-three/drei": "^10.7.6",
"@react-three/fiber": "^9.3.0",
"@react-three/postprocessing": "^3.0.4",
"@tanstack/react-query": "^5.90.8",
"next": "^15.5.2",
"react": "^19.1.1",
@ -1217,6 +1218,32 @@
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"license": "MIT"
},
"node_modules/@react-three/postprocessing": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@react-three/postprocessing/-/postprocessing-3.0.4.tgz",
"integrity": "sha512-e4+F5xtudDYvhxx3y0NtWXpZbwvQ0x1zdOXWTbXMK6fFLVDd4qucN90YaaStanZGS4Bd5siQm0lGL/5ogf8iDQ==",
"license": "MIT",
"dependencies": {
"maath": "^0.6.0",
"n8ao": "^1.9.4",
"postprocessing": "^6.36.6"
},
"peerDependencies": {
"@react-three/fiber": "^9.0.0",
"react": "^19.0",
"three": ">= 0.156.0"
}
},
"node_modules/@react-three/postprocessing/node_modules/maath": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/maath/-/maath-0.6.0.tgz",
"integrity": "sha512-dSb2xQuP7vDnaYqfoKzlApeRcR2xtN8/f7WV/TMAkBC8552TwTLtOO0JTcSygkYMjNDPoo6V01jTw/aPi4JrMw==",
"license": "MIT",
"peerDependencies": {
"@types/three": ">=0.144.0",
"three": ">=0.144.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@ -1967,6 +1994,16 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/n8ao": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/n8ao/-/n8ao-1.10.1.tgz",
"integrity": "sha512-hhI1pC+BfOZBV1KMwynBrVlIm8wqLxj/abAWhF2nZ0qQKyzTSQa1QtLVS2veRiuoBQXojxobcnp0oe+PUoxf/w==",
"license": "ISC",
"peerDependencies": {
"postprocessing": ">=6.30.0",
"three": ">=0.137"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -2128,6 +2165,16 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postprocessing": {
"version": "6.38.0",
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.38.0.tgz",
"integrity": "sha512-tisx8XN/PWTL3uXz2mt8bjlMS1wiOUSCK3ixi4zjwUCFmP8XW8hNhXwrxwd2zf2VmCyCQ3GUaLm7GLnkkBbDsQ==",
"license": "Zlib",
"peer": true,
"peerDependencies": {
"three": ">= 0.157.0 < 0.182.0"
}
},
"node_modules/potpack": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz",

View file

@ -18,6 +18,7 @@
"dependencies": {
"@react-three/drei": "^10.7.6",
"@react-three/fiber": "^9.3.0",
"@react-three/postprocessing": "^3.0.4",
"@tanstack/react-query": "^5.90.8",
"next": "^15.5.2",
"react": "^19.1.1",

View file

@ -13,6 +13,7 @@ export function setupColor(tex, repeat = [1, 1]) {
tex.wrapS = tex.wrapT = RepeatWrapping;
tex.colorSpace = SRGBColorSpace;
tex.repeat.set(...repeat);
tex.flipY = false; // DDS/DIF textures are already flipped
tex.anisotropy = 16;
tex.generateMipmaps = true;
tex.minFilter = LinearMipmapLinearFilter;