修复土星星环
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 606 KiB |
|
After Width: | Height: | Size: 809 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
|
@ -1,5 +1,5 @@
|
|||
import { useRef, useMemo, useState, useEffect, Suspense } from 'react';
|
||||
import { Mesh } from 'three';
|
||||
import { Mesh, Group, DoubleSide } from 'three';
|
||||
import * as THREE from 'three';
|
||||
import { useGLTF, useTexture } from '@react-three/drei';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
|
|
@ -12,10 +12,75 @@ interface BodyViewerProps {
|
|||
disableGlow?: boolean; // 禁用光晕效果(用于详情视图)
|
||||
}
|
||||
|
||||
// Reusable component to render just the 3D model/mesh of a celestial body
|
||||
// Planetary Rings component - handles texture-based rings
|
||||
function PlanetaryRings({ texturePath, planetRadius }: { texturePath?: string | null, planetRadius: number }) {
|
||||
const texture = texturePath ? useTexture(texturePath) : null;
|
||||
const meshRef = useRef<Mesh>(null);
|
||||
|
||||
// Dynamic ring dimensions based on planet size
|
||||
// Standard Saturn proportions: Inner ~1.1 R, Outer ~2.3 R
|
||||
const innerRadius = planetRadius * 1.05;
|
||||
const outerRadius = planetRadius * 2.2;
|
||||
|
||||
// Custom geometry with Polar UV mapping for strip textures
|
||||
const geometry = useMemo(() => {
|
||||
if (!texturePath) return null;
|
||||
|
||||
const geo = new THREE.RingGeometry(innerRadius, outerRadius, 64);
|
||||
const pos = geo.attributes.position;
|
||||
const uv = geo.attributes.uv;
|
||||
|
||||
// Manually remap UVs to be polar:
|
||||
// We map normalized radius to BOTH U and V coordinates.
|
||||
// This allows the texture to work whether it's a horizontal strip OR a vertical strip.
|
||||
// - If horizontal strip: U varies with radius (correct), V varies (doesn't matter as strip is uniform vertically)
|
||||
// - If vertical strip: V varies with radius (correct), U varies (doesn't matter as strip is uniform horizontally)
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
const x = pos.getX(i);
|
||||
const y = pos.getY(i);
|
||||
|
||||
// Calculate radius from center
|
||||
const r = Math.sqrt(x * x + y * y);
|
||||
|
||||
// Normalize radius: 0 at inner, 1 at outer
|
||||
// Clamp to ensure floating point errors don't break texture wrapping
|
||||
const normalizedR = Math.max(0, Math.min(1, (r - innerRadius) / (outerRadius - innerRadius)));
|
||||
|
||||
// Map to UV (same value for both axes to support H/V strips)
|
||||
uv.setXY(i, normalizedR, normalizedR);
|
||||
}
|
||||
|
||||
geo.attributes.uv.needsUpdate = true;
|
||||
return geo;
|
||||
}, [texturePath, innerRadius, outerRadius]);
|
||||
|
||||
if (texture && geometry) {
|
||||
return (
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
geometry={geometry}
|
||||
receiveShadow
|
||||
castShadow
|
||||
>
|
||||
<meshStandardMaterial
|
||||
map={texture}
|
||||
transparent
|
||||
opacity={0.9}
|
||||
side={DoubleSide}
|
||||
color="#ffffff"
|
||||
roughness={0.8}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
export function BodyViewer({ body, disableGlow = false }: BodyViewerProps) {
|
||||
const meshRef = useRef<Mesh>(null);
|
||||
const [texturePath, setTexturePath] = useState<string | null | undefined>(undefined);
|
||||
const [ringTexturePath, setRingTexturePath] = useState<string | null | undefined>(undefined);
|
||||
const [modelPath, setModelPath] = useState<string | null | undefined>(undefined);
|
||||
const [modelScale, setModelScale] = useState<number>(1.0);
|
||||
const [loadError, setLoadError] = useState<boolean>(false);
|
||||
|
|
@ -62,31 +127,62 @@ export function BodyViewer({ body, disableGlow = false }: BodyViewerProps) {
|
|||
setLoadError(false);
|
||||
setModelPath(undefined);
|
||||
setTexturePath(undefined);
|
||||
setRingTexturePath(undefined);
|
||||
|
||||
const loadResources = async () => {
|
||||
try {
|
||||
const response = await fetchBodyResources(body.id, body.type === 'probe' ? 'model' : 'texture');
|
||||
if (response.resources.length > 0) {
|
||||
const mainResource = response.resources[0];
|
||||
const fullPath = `/upload/${mainResource.file_path}`;
|
||||
|
||||
if (body.type === 'probe') {
|
||||
const mainResource = response.resources[0];
|
||||
const fullPath = `/upload/${mainResource.file_path}`;
|
||||
useGLTF.preload(fullPath); // Preload GLTF
|
||||
setModelPath(fullPath);
|
||||
setModelScale(mainResource.extra_data?.scale || 1.0);
|
||||
} else {
|
||||
setTexturePath(fullPath);
|
||||
// Find main texture
|
||||
const mainTexture = response.resources.find(
|
||||
(r) => !r.file_path.includes('atmosphere') &&
|
||||
!r.file_path.includes('night') &&
|
||||
!r.file_path.includes('_ring')
|
||||
);
|
||||
|
||||
// Find ring texture
|
||||
const bodyNameLower = body.name.toLowerCase();
|
||||
const ringTexture = response.resources.find(
|
||||
(r) => r.file_path.toLowerCase().includes(`${bodyNameLower}_ring`) ||
|
||||
(r.file_path.toLowerCase().includes('ring') && r.file_path.toLowerCase().includes(bodyNameLower))
|
||||
);
|
||||
|
||||
if (mainTexture) {
|
||||
setTexturePath(`/upload/${mainTexture.file_path}`);
|
||||
} else {
|
||||
setTexturePath(null);
|
||||
}
|
||||
|
||||
if (ringTexture) {
|
||||
setRingTexturePath(`/upload/${ringTexture.file_path}`);
|
||||
} else {
|
||||
setRingTexturePath(null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No resources found
|
||||
if (body.type === 'probe') setModelPath(null);
|
||||
else setTexturePath(null);
|
||||
else {
|
||||
setTexturePath(null);
|
||||
setRingTexturePath(null);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load resource for ${body.name}:`, err);
|
||||
setLoadError(true);
|
||||
if (body.type === 'probe') setModelPath(null);
|
||||
else setTexturePath(null);
|
||||
else {
|
||||
setTexturePath(null);
|
||||
setRingTexturePath(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadResources();
|
||||
|
|
@ -115,6 +211,7 @@ export function BodyViewer({ body, disableGlow = false }: BodyViewerProps) {
|
|||
emissive={appearance.emissive}
|
||||
emissiveIntensity={appearance.emissiveIntensity}
|
||||
texturePath={texturePath}
|
||||
ringTexturePath={ringTexturePath}
|
||||
meshRef={meshRef}
|
||||
disableGlow={disableGlow}
|
||||
/>
|
||||
|
|
@ -179,12 +276,13 @@ function ProbeModelViewer({ modelPath, modelScale }: { modelPath: string; modelS
|
|||
}
|
||||
|
||||
// Sub-component for Planet models
|
||||
function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePath, meshRef, disableGlow = false }: {
|
||||
function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePath, ringTexturePath, meshRef, disableGlow = false }: {
|
||||
body: CelestialBodyType;
|
||||
size: number;
|
||||
emissive: string;
|
||||
emissiveIntensity: number;
|
||||
texturePath: string | null;
|
||||
ringTexturePath?: string | null;
|
||||
meshRef: React.RefObject<Mesh>;
|
||||
disableGlow?: boolean;
|
||||
}) {
|
||||
|
|
@ -331,39 +429,6 @@ function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePat
|
|||
);
|
||||
}
|
||||
|
||||
// Saturn Rings component - multiple rings for band effect
|
||||
function SaturnRings() {
|
||||
return (
|
||||
<group rotation={[Math.PI / 2, 0, 0]}>
|
||||
{/* Inner bright ring */}
|
||||
<mesh>
|
||||
<ringGeometry args={[1.4, 1.6, 32]} />
|
||||
<meshBasicMaterial color="#D4B896" transparent opacity={0.7} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
{/* Middle darker band */}
|
||||
<mesh>
|
||||
<ringGeometry args={[1.6, 1.75, 32]} />
|
||||
<meshBasicMaterial color="#8B7355" transparent opacity={0.5} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
{/* Outer bright ring */}
|
||||
<mesh>
|
||||
<ringGeometry args={[1.75, 2.0, 32]} />
|
||||
<meshBasicMaterial color="#C4A582" transparent opacity={0.6} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
{/* Cassini Division (gap) */}
|
||||
<mesh>
|
||||
<ringGeometry args={[2.0, 2.05, 32]} />
|
||||
<meshBasicMaterial color="#000000" transparent opacity={0.2} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
{/* A Ring (outer) */}
|
||||
<mesh>
|
||||
<ringGeometry args={[2.05, 2.2, 32]} />
|
||||
<meshBasicMaterial color="#B89968" transparent opacity={0.5} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Use irregular nucleus for comets, regular sphere for others */}
|
||||
|
|
@ -395,8 +460,10 @@ function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePat
|
|||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Saturn Rings */}
|
||||
{body.id === '699' && <SaturnRings />}
|
||||
{/* Planetary Rings: Render ONLY if texture exists */}
|
||||
{ringTexturePath && (
|
||||
<PlanetaryRings texturePath={ringTexturePath} planetRadius={size} />
|
||||
)}
|
||||
|
||||
{/* Sun glow effect - multi-layer scattered light (仅在非禁用光晕模式下显示) */}
|
||||
{body.type === 'star' && !disableGlow && (
|
||||
|
|
|
|||
|
|
@ -19,64 +19,72 @@ interface CelestialBodyProps {
|
|||
onBodySelect?: (body: CelestialBodyType) => void;
|
||||
}
|
||||
|
||||
// Saturn Rings component - multiple rings for band effect
|
||||
function SaturnRings() {
|
||||
// Planetary Rings component - handles texture-based rings
|
||||
function PlanetaryRings({ texturePath, planetRadius }: { texturePath?: string | null, planetRadius: number }) {
|
||||
const texture = texturePath ? useTexture(texturePath) : null;
|
||||
const meshRef = useRef<Mesh>(null);
|
||||
|
||||
// Dynamic ring dimensions based on planet size
|
||||
// Standard Saturn proportions: Inner ~1.1 R, Outer ~2.3 R
|
||||
const innerRadius = planetRadius * 1.05;
|
||||
const outerRadius = planetRadius * 2.2;
|
||||
|
||||
// Custom geometry with Polar UV mapping for strip textures
|
||||
const geometry = useMemo(() => {
|
||||
if (!texturePath) return null;
|
||||
|
||||
const geo = new THREE.RingGeometry(innerRadius, outerRadius, 64);
|
||||
const pos = geo.attributes.position;
|
||||
const uv = geo.attributes.uv;
|
||||
|
||||
// Manually remap UVs to be polar:
|
||||
// We map normalized radius to BOTH U and V coordinates.
|
||||
// This allows the texture to work whether it's a horizontal strip OR a vertical strip.
|
||||
// - If horizontal strip: U varies with radius (correct), V varies (doesn't matter as strip is uniform vertically)
|
||||
// - If vertical strip: V varies with radius (correct), U varies (doesn't matter as strip is uniform horizontally)
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
const x = pos.getX(i);
|
||||
const y = pos.getY(i);
|
||||
|
||||
// Calculate radius from center
|
||||
const r = Math.sqrt(x * x + y * y);
|
||||
|
||||
// Normalize radius: 0 at inner, 1 at outer
|
||||
// Clamp to ensure floating point errors don't break texture wrapping
|
||||
const normalizedR = Math.max(0, Math.min(1, (r - innerRadius) / (outerRadius - innerRadius)));
|
||||
|
||||
// Map to UV (same value for both axes to support H/V strips)
|
||||
uv.setXY(i, normalizedR, normalizedR);
|
||||
}
|
||||
|
||||
geo.attributes.uv.needsUpdate = true;
|
||||
return geo;
|
||||
}, [texturePath, innerRadius, outerRadius]);
|
||||
|
||||
if (texture && geometry) {
|
||||
return (
|
||||
<group rotation={[Math.PI / 2, 0, 0]}>
|
||||
{/* Inner bright ring */}
|
||||
<mesh>
|
||||
<ringGeometry args={[1.4, 1.6, 32]} />
|
||||
<meshBasicMaterial
|
||||
color="#D4B896"
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
geometry={geometry}
|
||||
receiveShadow
|
||||
castShadow
|
||||
>
|
||||
<meshStandardMaterial
|
||||
map={texture}
|
||||
transparent
|
||||
opacity={0.7}
|
||||
opacity={0.9}
|
||||
side={DoubleSide}
|
||||
color="#ffffff"
|
||||
roughness={0.8}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Middle darker band */}
|
||||
<mesh>
|
||||
<ringGeometry args={[1.6, 1.75, 32]} />
|
||||
<meshBasicMaterial
|
||||
color="#8B7355"
|
||||
transparent
|
||||
opacity={0.5}
|
||||
side={DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Outer bright ring */}
|
||||
<mesh>
|
||||
<ringGeometry args={[1.75, 2.0, 32]} />
|
||||
<meshBasicMaterial
|
||||
color="#C4A582"
|
||||
transparent
|
||||
opacity={0.6}
|
||||
side={DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Cassini Division (gap) */}
|
||||
<mesh>
|
||||
<ringGeometry args={[2.0, 2.05, 32]} />
|
||||
<meshBasicMaterial
|
||||
color="#000000"
|
||||
transparent
|
||||
opacity={0.2}
|
||||
side={DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
{/* A Ring (outer) */}
|
||||
<mesh>
|
||||
<ringGeometry args={[2.05, 2.2, 32]} />
|
||||
<meshBasicMaterial
|
||||
color="#B89968"
|
||||
transparent
|
||||
opacity={0.5}
|
||||
side={DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Planet component with texture
|
||||
function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected = false, onBodySelect }: {
|
||||
body: CelestialBodyType;
|
||||
|
|
@ -90,6 +98,7 @@ function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected
|
|||
const meshRef = useRef<Mesh>(null);
|
||||
const position = body.positions[0];
|
||||
const [texturePath, setTexturePath] = useState<string | null | undefined>(undefined);
|
||||
const [ringTexturePath, setRingTexturePath] = useState<string | null | undefined>(undefined);
|
||||
|
||||
// Use smart render position calculation
|
||||
const renderPosition = useMemo(() => {
|
||||
|
|
@ -102,23 +111,40 @@ function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected
|
|||
useEffect(() => {
|
||||
fetchBodyResources(body.id, 'texture')
|
||||
.then((response) => {
|
||||
// Find the main texture (not atmosphere or night layers)
|
||||
// 1. Find the main texture (body surface)
|
||||
// Exclude atmosphere, night, and ring textures from main body texture
|
||||
const mainTexture = response.resources.find(
|
||||
(r) => !r.file_path.includes('atmosphere') && !r.file_path.includes('night')
|
||||
(r) => !r.file_path.includes('atmosphere') &&
|
||||
!r.file_path.includes('night') &&
|
||||
!r.file_path.includes('_ring')
|
||||
);
|
||||
|
||||
// 2. Find the ring texture
|
||||
// Convention: filename contains body name + "_ring" (e.g. "saturn_ring")
|
||||
const bodyNameLower = body.name.toLowerCase();
|
||||
const ringTexture = response.resources.find(
|
||||
(r) => r.file_path.toLowerCase().includes(`${bodyNameLower}_ring`) ||
|
||||
(r.file_path.toLowerCase().includes('ring') && r.file_path.toLowerCase().includes(bodyNameLower))
|
||||
);
|
||||
|
||||
if (mainTexture) {
|
||||
// Construct path for Nginx proxy
|
||||
// file_path is like "texture/2k_sun.jpg", need to add "upload/" prefix
|
||||
setTexturePath(`/upload/${mainTexture.file_path}`);
|
||||
} else {
|
||||
setTexturePath(null);
|
||||
}
|
||||
|
||||
if (ringTexture) {
|
||||
setRingTexturePath(`/upload/${ringTexture.file_path}`);
|
||||
} else {
|
||||
setRingTexturePath(null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error(`Failed to load texture for ${body.name}:`, err);
|
||||
}
|
||||
setTexturePath(null);
|
||||
setRingTexturePath(null);
|
||||
});
|
||||
}, [body.id, body.name]);
|
||||
|
||||
|
|
@ -134,6 +160,7 @@ function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected
|
|||
emissiveIntensity={emissiveIntensity}
|
||||
scaledPos={scaledPos}
|
||||
texturePath={texturePath}
|
||||
ringTexturePath={ringTexturePath}
|
||||
position={position}
|
||||
meshRef={meshRef}
|
||||
hasOffset={renderPosition.hasOffset}
|
||||
|
|
@ -284,13 +311,14 @@ function CometComa({ radius }: { radius: number }) {
|
|||
}
|
||||
|
||||
// Separate component to handle texture loading
|
||||
function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, texturePath, position, meshRef, hasOffset, allBodies, isSelected = false, onBodySelect }: {
|
||||
function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, texturePath, ringTexturePath, position, meshRef, hasOffset, allBodies, isSelected = false, onBodySelect }: {
|
||||
body: CelestialBodyType;
|
||||
size: number;
|
||||
emissive: string;
|
||||
emissiveIntensity: number;
|
||||
scaledPos: { x: number; y: number; z: number };
|
||||
texturePath: string | null;
|
||||
ringTexturePath?: string | null;
|
||||
position: { x: number; y: number; z: number };
|
||||
meshRef: React.RefObject<Mesh>;
|
||||
hasOffset: boolean;
|
||||
|
|
@ -376,8 +404,10 @@ function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, textur
|
|||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Saturn Rings */}
|
||||
{body.id === '699' && <SaturnRings />}
|
||||
{/* Planetary Rings: Render ONLY if texture exists (Standardized) */}
|
||||
{ringTexturePath && (
|
||||
<PlanetaryRings texturePath={ringTexturePath} planetRadius={size} />
|
||||
)}
|
||||
|
||||
{/* Sun glow effect - multi-layer scattered light */}
|
||||
{body.type === 'star' && (
|
||||
|
|
|
|||