3 Commits

Author SHA1 Message Date
Tom Boullay cf71148935 fix: smooth gallery preview seams
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-25 18:02:36 +02:00
Tom Boullay 1b2241df49 feat: add gallery lighting controls 2026-05-25 17:57:51 +02:00
Tom Boullay d7351e5f37 fix: render gallery skybox unlit double-sided 2026-05-25 17:53:46 +02:00
4 changed files with 387 additions and 43 deletions
+7 -3
View File
@@ -4,14 +4,15 @@ La galerie est disponible sur `/gallery`. Elle permet de parcourir les modèles
## Objectif
Cette page sert à remercier et valoriser le travail des designers du projet La Fabrik. Chaque modèle est affiché dans un canvas dédié, avec la même skybox que l'expérience principale pour garder une ambiance visuelle cohérente.
Cette page sert à remercier et valoriser le travail des designers du projet La Fabrik. Chaque modèle est affiché dans un canvas dédié, avec la même skybox et le même lighting que l'expérience principale.
## Utilisation
1. Ouvrir `/gallery`.
2. Utiliser les flèches en bas de l'écran pour passer au modèle précédent ou suivant.
3. Tourner autour du modèle avec la souris ou le doigt.
4. Lire le diagnostic texture discret pour savoir si le modèle chargé semble correct côté textures.
4. Utiliser le bouton de réglages à droite pour ouvrir ou fermer le panneau lumière.
5. Lire le diagnostic texture discret pour savoir si le modèle chargé semble correct côté textures.
## Fonctionnement
@@ -19,7 +20,10 @@ Cette page sert à remercier et valoriser le travail des designers du projet La
- Le viewer utilise `@react-three/fiber` et `@react-three/drei`.
- `OrbitControls` permet de manipuler la caméra autour du modèle.
- `Bounds` et `Center` recadrent automatiquement le modèle actif.
- `SkyModel` réutilise la skybox du jeu.
- `SkyModel` réutilise la skybox du jeu, avec un matériau non éclairé uniquement dans la galerie pour éviter que certaines faces deviennent noires avec une caméra orbitale libre.
- Les lumières reprennent les valeurs par défaut du jeu, puis peuvent être ajustées dans le panneau latéral.
- `OrbitControls` autorise une orbite verticale complète pour inspecter le dessous des modèles.
- Le viewer désactive les normal maps dans la preview pour limiter les coutures visibles sur certains exports découpés en plusieurs meshes.
- Les animations GLTF présentes dans un modèle sont lancées automatiquement.
- Un diagnostic simple inspecte les matériaux chargés pour signaler les textures absentes ou non exploitables.
+57 -10
View File
@@ -8,12 +8,16 @@ interface SkyModelProps {
modelPath: string;
fallbackModelPath?: string | undefined;
fallbackScale?: number | undefined;
materialSide?: THREE.Side | undefined;
scale?: number | undefined;
unlit?: boolean | undefined;
}
interface SkyModelContentProps {
materialSide: THREE.Side;
modelPath: string;
scale: number;
unlit: boolean;
}
interface SkyModelErrorBoundaryProps {
@@ -54,23 +58,37 @@ class SkyModelErrorBoundary extends Component<
export function SkyModel({
fallbackModelPath,
fallbackScale = SKY_MODEL_SCALE,
materialSide = THREE.BackSide,
modelPath,
scale = SKY_MODEL_SCALE,
unlit = false,
}: SkyModelProps): React.JSX.Element {
const fallback = fallbackModelPath ? (
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} />
<SkyModelContent
materialSide={materialSide}
modelPath={fallbackModelPath}
scale={fallbackScale}
unlit={unlit}
/>
) : null;
return (
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
<SkyModelContent modelPath={modelPath} scale={scale} />
<SkyModelContent
materialSide={materialSide}
modelPath={modelPath}
scale={scale}
unlit={unlit}
/>
</SkyModelErrorBoundary>
);
}
function SkyModelContent({
materialSide,
modelPath,
scale,
unlit,
}: SkyModelContentProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
const groupRef = useRef<THREE.Group>(null);
@@ -78,7 +96,10 @@ function SkyModelContent({
scope: "SkyModel",
scale,
});
const model = useMemo(() => createSkyModel(scene), [scene]);
const model = useMemo(
() => createSkyModel(scene, materialSide, unlit),
[materialSide, scene, unlit],
);
useEffect(() => {
return () => {
@@ -102,7 +123,11 @@ function SkyModelContent({
);
}
function createSkyModel(scene: THREE.Object3D): THREE.Object3D {
function createSkyModel(
scene: THREE.Object3D,
materialSide: THREE.Side,
unlit: boolean,
): THREE.Object3D {
const model = scene.clone(true);
model.traverse((object) => {
@@ -112,20 +137,42 @@ function createSkyModel(scene: THREE.Object3D): THREE.Object3D {
if (!(object instanceof THREE.Mesh)) return;
object.material = Array.isArray(object.material)
? object.material.map(createSkyMaterial)
: createSkyMaterial(object.material);
? object.material.map((material) =>
createSkyMaterial(material, materialSide, unlit),
)
: createSkyMaterial(object.material, materialSide, unlit);
});
return model;
}
function createSkyMaterial<T extends THREE.Material>(material: T): T {
const skyMaterial = material.clone();
skyMaterial.side = THREE.BackSide;
function createSkyMaterial<T extends THREE.Material>(
material: T,
materialSide: THREE.Side,
unlit: boolean,
): THREE.Material {
const skyMaterial = unlit
? createUnlitSkyMaterial(material)
: material.clone();
skyMaterial.side = materialSide;
skyMaterial.depthTest = false;
skyMaterial.depthWrite = false;
return skyMaterial as T;
return skyMaterial;
}
function createUnlitSkyMaterial(
material: THREE.Material,
): THREE.MeshBasicMaterial {
const sourceMaterial = material as THREE.MeshStandardMaterial;
return new THREE.MeshBasicMaterial({
color: sourceMaterial.color?.clone() ?? new THREE.Color("#ffffff"),
map: sourceMaterial.map ?? null,
opacity: sourceMaterial.opacity,
toneMapped: false,
transparent: sourceMaterial.transparent,
});
}
function disposeSkyModelMaterials(model: THREE.Object3D): void {
+126 -23
View File
@@ -36,8 +36,9 @@ canvas {
width: 100vw;
height: 100vh;
overflow: hidden;
background: #05070c;
color: #f8fafc;
background: #050505;
color: #f4efe7;
font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif;
}
.gallery-title {
@@ -46,7 +47,7 @@ canvas {
right: clamp(18px, 3vw, 38px);
z-index: 2;
margin: 0;
color: rgba(248, 250, 252, 0.92);
color: #f4efe7;
font-size: clamp(18px, 2vw, 26px);
font-weight: 700;
letter-spacing: 0.32em;
@@ -78,12 +79,11 @@ canvas {
grid-template-columns: 54px minmax(190px, 340px) 54px;
align-items: center;
overflow: hidden;
border: 1px solid rgba(248, 250, 252, 0.18);
border-radius: 999px;
background: rgba(3, 7, 18, 0.72);
box-shadow: 0 18px 52px rgba(0, 0, 0, 0.32);
border: 2px solid #d8d0c4;
border-radius: 0;
background: #050505;
box-shadow: none;
transform: translateX(50%);
backdrop-filter: blur(18px);
}
.gallery-bottom-bar button {
@@ -93,7 +93,7 @@ canvas {
height: 54px;
border: 0;
background: transparent;
color: rgba(248, 250, 252, 0.82);
color: #f4efe7;
cursor: pointer;
transition:
background 160ms ease,
@@ -102,8 +102,8 @@ canvas {
.gallery-bottom-bar button:hover,
.gallery-bottom-bar button:focus-visible {
background: rgba(248, 250, 252, 0.1);
color: #f8fafc;
background: #f4efe7;
color: #050505;
outline: none;
}
@@ -112,15 +112,15 @@ canvas {
place-items: center;
min-height: 54px;
padding: 0 20px;
border-right: 1px solid rgba(248, 250, 252, 0.14);
border-left: 1px solid rgba(248, 250, 252, 0.14);
border-right: 2px solid #d8d0c4;
border-left: 2px solid #d8d0c4;
text-align: center;
}
.gallery-model-info span {
max-width: 100%;
overflow: hidden;
color: #f8fafc;
color: #f4efe7;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.03em;
@@ -131,7 +131,7 @@ canvas {
.gallery-model-info small {
margin-top: 2px;
color: rgba(203, 213, 225, 0.62);
color: #a9a196;
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 11px;
font-weight: 600;
@@ -147,26 +147,125 @@ canvas {
gap: 8px;
max-width: min(320px, calc(100vw - 36px));
padding: 10px 13px;
border: 1px solid rgba(248, 250, 252, 0.14);
border-radius: 999px;
background: rgba(3, 7, 18, 0.58);
color: rgba(226, 232, 240, 0.86);
border: 2px solid #d8d0c4;
border-radius: 0;
background: #050505;
color: #d8d0c4;
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 12px;
font-weight: 700;
backdrop-filter: blur(16px);
}
.gallery-texture-status--ok {
color: #bbf7d0;
color: #d8d0c4;
}
.gallery-texture-status--warning {
color: #fde68a;
color: #f4efe7;
}
.gallery-texture-status--loading {
color: rgba(226, 232, 240, 0.72);
color: #a9a196;
}
.gallery-light-panel {
position: absolute;
top: 108px;
right: 0;
z-index: 3;
display: flex;
align-items: flex-start;
transform: translateX(260px);
transition: transform 180ms ease;
}
.gallery-light-panel.is-open {
transform: translateX(0);
}
.gallery-light-panel-toggle {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border: 2px solid #d8d0c4;
border-right: 0;
border-radius: 0;
background: #050505;
color: #f4efe7;
cursor: pointer;
}
.gallery-light-panel-toggle:hover,
.gallery-light-panel-toggle:focus-visible {
background: #f4efe7;
color: #050505;
outline: none;
}
.gallery-light-panel-content {
width: 236px;
padding: 16px;
border: 2px solid #d8d0c4;
border-right: 0;
border-radius: 0;
background: #050505;
box-shadow: none;
}
.gallery-light-panel-content header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.gallery-light-panel-content header span {
color: #f4efe7;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.18em;
}
.gallery-light-panel-content header button {
border: 0;
background: transparent;
color: #a9a196;
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
.gallery-light-panel-content header button:hover,
.gallery-light-panel-content header button:focus-visible {
color: #f4efe7;
outline: none;
}
.gallery-light-control {
display: grid;
gap: 8px;
margin-top: 12px;
}
.gallery-light-control span {
display: flex;
align-items: center;
justify-content: space-between;
color: #d8d0c4;
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 12px;
font-weight: 700;
}
.gallery-light-control strong {
color: #f4efe7;
font-variant-numeric: tabular-nums;
}
.gallery-light-control input {
width: 100%;
accent-color: #f4efe7;
}
@media (max-width: 720px) {
@@ -196,6 +295,10 @@ canvas {
left: auto;
transform: translateX(50%);
}
.gallery-light-panel {
top: 78px;
}
}
/* Docs layout */
+197 -7
View File
@@ -19,26 +19,53 @@ import {
ArrowLeft,
ArrowRight,
CheckCircle2,
SlidersHorizontal,
TriangleAlert,
} from "lucide-react";
import * as THREE from "three";
import { SkyModel } from "@/components/three/world/SkyModel";
import { galleryModels, type GalleryModel } from "@/data/galleryModels";
import {
AMBIENT_LIGHT_COLOR,
LIGHTING_DEFAULTS,
SUN_LIGHT_COLOR,
} from "@/data/world/lightingConfig";
import {
GAME_SCENE_FALLBACK_SKY_MODEL_PATH,
GAME_SCENE_FALLBACK_SKY_MODEL_SCALE,
GAME_SCENE_SKY_MODEL_PATH,
GAME_SCENE_SKY_MODEL_SCALE,
} from "@/data/world/environmentConfig";
import { galleryModels, type GalleryModel } from "@/data/galleryModels";
interface GalleryModelProps {
model: GalleryModel;
}
interface GallerySceneProps extends GalleryModelProps {
lighting: GalleryLightingConfig;
onTextureDiagnosticReady: (diagnostic: TextureDiagnostic) => void;
}
interface GalleryModelPreviewProps extends GalleryModelProps {
onTextureDiagnosticReady: (diagnostic: TextureDiagnostic) => void;
}
interface GalleryLightingConfig {
ambientIntensity: number;
sunIntensity: number;
sunX: number;
sunY: number;
sunZ: number;
}
interface GalleryLightControl {
key: keyof GalleryLightingConfig;
label: string;
min: number;
max: number;
step: number;
}
interface TextureDiagnostic {
modelId: string | null;
status: "loading" | "ok" | "warning";
@@ -70,6 +97,14 @@ const LOADING_TEXTURE_DIAGNOSTIC: TextureDiagnostic = {
summary: "Analyse des textures...",
};
const GALLERY_LIGHT_CONTROLS: GalleryLightControl[] = [
{ key: "ambientIntensity", label: "Ambiance", min: 0, max: 5, step: 0.1 },
{ key: "sunIntensity", label: "Soleil", min: 0, max: 8, step: 0.1 },
{ key: "sunX", label: "Soleil X", min: -100, max: 100, step: 1 },
{ key: "sunY", label: "Soleil Y", min: -100, max: 150, step: 1 },
{ key: "sunZ", label: "Soleil Z", min: -100, max: 100, step: 1 },
];
class GalleryViewerErrorBoundary extends Component<
GalleryViewerErrorBoundaryProps,
GalleryViewerErrorBoundaryState
@@ -105,12 +140,18 @@ class GalleryViewerErrorBoundary extends Component<
function GalleryModelPreview({
model,
onTextureDiagnosticReady,
}: GallerySceneProps): React.JSX.Element {
}: GalleryModelPreviewProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const { animations, scene } = useGLTF(model.path);
const modelScene = useMemo(() => scene.clone(true), [scene]);
const modelScene = useMemo(() => createGalleryModelScene(scene), [scene]);
const { actions } = useAnimations(animations, groupRef);
useEffect(() => {
return () => {
disposeGalleryModelMaterials(modelScene);
};
}, [modelScene]);
useEffect(() => {
onTextureDiagnosticReady(getTextureDiagnostic(model.id, modelScene));
}, [model.id, modelScene, onTextureDiagnosticReady]);
@@ -138,7 +179,53 @@ function GalleryModelPreview({
);
}
function createGalleryModelScene(scene: THREE.Object3D): THREE.Object3D {
const modelScene = scene.clone(true);
modelScene.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
object.material = Array.isArray(object.material)
? object.material.map(createGalleryMaterial)
: createGalleryMaterial(object.material);
});
return modelScene;
}
function createGalleryMaterial(material: THREE.Material): THREE.Material {
const galleryMaterial = material.clone();
const materialWithNormalMap = galleryMaterial as THREE.Material & {
normalMap?: THREE.Texture | null;
};
galleryMaterial.side = THREE.DoubleSide;
if (materialWithNormalMap.normalMap) {
materialWithNormalMap.normalMap = null;
galleryMaterial.needsUpdate = true;
}
return galleryMaterial;
}
function disposeGalleryModelMaterials(modelScene: THREE.Object3D): void {
modelScene.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
if (Array.isArray(object.material)) {
for (const material of object.material) {
material.dispose();
}
return;
}
object.material.dispose();
});
}
function GalleryScene({
lighting,
model,
onTextureDiagnosticReady,
}: GallerySceneProps): React.JSX.Element {
@@ -147,11 +234,12 @@ function GalleryScene({
<SkyModel
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
materialSide={THREE.DoubleSide}
modelPath={GAME_SCENE_SKY_MODEL_PATH}
scale={GAME_SCENE_SKY_MODEL_SCALE}
unlit
/>
<ambientLight intensity={0.75} />
<directionalLight position={[6, 8, 4]} intensity={2.1} />
<GalleryLighting lighting={lighting} />
<Bounds fit clip observe margin={1.35}>
<Center>
<GalleryModelPreview
@@ -165,8 +253,28 @@ function GalleryScene({
enableDamping
autoRotate
autoRotateSpeed={0.5}
minPolarAngle={Math.PI * 0.18}
maxPolarAngle={Math.PI * 0.48}
minPolarAngle={0}
maxPolarAngle={Math.PI}
/>
</>
);
}
function GalleryLighting({
lighting,
}: {
lighting: GalleryLightingConfig;
}): React.JSX.Element {
return (
<>
<ambientLight
intensity={lighting.ambientIntensity}
color={AMBIENT_LIGHT_COLOR}
/>
<directionalLight
position={[lighting.sunX, lighting.sunY, lighting.sunZ]}
intensity={lighting.sunIntensity}
color={SUN_LIGHT_COLOR}
/>
</>
);
@@ -190,6 +298,62 @@ function TextureStatusBadge({
);
}
function GalleryLightingPanel({
lighting,
onChange,
onReset,
onToggle,
open,
}: {
lighting: GalleryLightingConfig;
onChange: (key: keyof GalleryLightingConfig, value: number) => void;
onReset: () => void;
onToggle: () => void;
open: boolean;
}): React.JSX.Element {
return (
<aside className={`gallery-light-panel ${open ? "is-open" : ""}`}>
<button
type="button"
className="gallery-light-panel-toggle"
onClick={onToggle}
aria-expanded={open}
aria-label={
open ? "Fermer les réglages lumière" : "Ouvrir les réglages lumière"
}
>
<SlidersHorizontal aria-hidden="true" size={18} strokeWidth={1.8} />
</button>
<div className="gallery-light-panel-content" aria-hidden={!open}>
<header>
<span>LIGHTS</span>
<button type="button" onClick={onReset}>
Reset
</button>
</header>
{GALLERY_LIGHT_CONTROLS.map((control) => (
<label key={control.key} className="gallery-light-control">
<span>
{control.label}
<strong>{lighting[control.key].toFixed(1)}</strong>
</span>
<input
type="range"
min={control.min}
max={control.max}
step={control.step}
value={lighting[control.key]}
onChange={(event) =>
onChange(control.key, Number(event.currentTarget.value))
}
/>
</label>
))}
</div>
</aside>
);
}
function getTextureDiagnostic(
modelId: string,
modelScene: THREE.Object3D,
@@ -245,6 +409,10 @@ function getTextureDiagnostic(
export function GalleryPage(): React.JSX.Element {
const [activeModelIndex, setActiveModelIndex] = useState(0);
const [lightPanelOpen, setLightPanelOpen] = useState(false);
const [lighting, setLighting] = useState<GalleryLightingConfig>({
...LIGHTING_DEFAULTS,
});
const [textureDiagnostic, setTextureDiagnostic] = useState<TextureDiagnostic>(
LOADING_TEXTURE_DIAGNOSTIC,
);
@@ -267,6 +435,20 @@ export function GalleryPage(): React.JSX.Element {
);
};
const handleLightChange = (
key: keyof GalleryLightingConfig,
value: number,
): void => {
setLighting((currentLighting) => ({
...currentLighting,
[key]: value,
}));
};
const resetLighting = (): void => {
setLighting({ ...LIGHTING_DEFAULTS });
};
return (
<main className="gallery-page">
<h1 className="gallery-title">GALERIE</h1>
@@ -276,6 +458,7 @@ export function GalleryPage(): React.JSX.Element {
<Canvas camera={{ position: [3.5, 2.4, 4.5], fov: 45 }} dpr={[1, 2]}>
<Suspense fallback={null}>
<GalleryScene
lighting={lighting}
model={activeModel}
onTextureDiagnosticReady={setTextureDiagnostic}
/>
@@ -308,6 +491,13 @@ export function GalleryPage(): React.JSX.Element {
</nav>
<TextureStatusBadge diagnostic={activeTextureDiagnostic} />
<GalleryLightingPanel
lighting={lighting}
onChange={handleLightChange}
onReset={resetLighting}
onToggle={() => setLightPanelOpen((open) => !open)}
open={lightPanelOpen}
/>
</main>
);
}