feat: add gallery lighting controls

This commit is contained in:
Tom Boullay
2026-05-25 17:57:51 +02:00
parent d7351e5f37
commit 1b2241df49
3 changed files with 251 additions and 6 deletions
+4 -1
View File
@@ -11,7 +11,8 @@ Cette page sert à remercier et valoriser le travail des designers du projet La
1. Ouvrir `/gallery`. 1. Ouvrir `/gallery`.
2. Utiliser les flèches en bas de l'écran pour passer au modèle précédent ou suivant. 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. 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 ## Fonctionnement
@@ -20,6 +21,8 @@ Cette page sert à remercier et valoriser le travail des designers du projet La
- `OrbitControls` permet de manipuler la caméra autour du modèle. - `OrbitControls` permet de manipuler la caméra autour du modèle.
- `Bounds` et `Center` recadrent automatiquement le modèle actif. - `Bounds` et `Center` recadrent automatiquement le modèle actif.
- `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. - `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.
- Les animations GLTF présentes dans un modèle sont lancées automatiquement. - 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. - Un diagnostic simple inspecte les matériaux chargés pour signaler les textures absentes ou non exploitables.
+105
View File
@@ -169,6 +169,107 @@ canvas {
color: rgba(226, 232, 240, 0.72); color: rgba(226, 232, 240, 0.72);
} }
.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: 1px solid rgba(248, 250, 252, 0.14);
border-right: 0;
border-radius: 999px 0 0 999px;
background: rgba(3, 7, 18, 0.68);
color: rgba(248, 250, 252, 0.84);
cursor: pointer;
backdrop-filter: blur(14px);
}
.gallery-light-panel-toggle:hover,
.gallery-light-panel-toggle:focus-visible {
color: #f8fafc;
outline: none;
}
.gallery-light-panel-content {
width: 236px;
padding: 16px;
border: 1px solid rgba(248, 250, 252, 0.14);
border-right: 0;
border-radius: 18px 0 0 18px;
background: rgba(3, 7, 18, 0.72);
box-shadow: 0 18px 52px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18px);
}
.gallery-light-panel-content header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.gallery-light-panel-content header span {
color: rgba(248, 250, 252, 0.86);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.18em;
}
.gallery-light-panel-content header button {
border: 0;
background: transparent;
color: rgba(203, 213, 225, 0.72);
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
.gallery-light-panel-content header button:hover,
.gallery-light-panel-content header button:focus-visible {
color: #f8fafc;
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: rgba(226, 232, 240, 0.78);
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 12px;
font-weight: 700;
}
.gallery-light-control strong {
color: rgba(248, 250, 252, 0.88);
font-variant-numeric: tabular-nums;
}
.gallery-light-control input {
width: 100%;
accent-color: #dbeafe;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.gallery-title { .gallery-title {
right: 50%; right: 50%;
@@ -196,6 +297,10 @@ canvas {
left: auto; left: auto;
transform: translateX(50%); transform: translateX(50%);
} }
.gallery-light-panel {
top: 78px;
}
} }
/* Docs layout */ /* Docs layout */
+142 -5
View File
@@ -19,27 +19,53 @@ import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
CheckCircle2, CheckCircle2,
SlidersHorizontal,
TriangleAlert, TriangleAlert,
} from "lucide-react"; } from "lucide-react";
import * as THREE from "three"; import * as THREE from "three";
import { SkyModel } from "@/components/three/world/SkyModel"; import { SkyModel } from "@/components/three/world/SkyModel";
import { galleryModels, type GalleryModel } from "@/data/galleryModels"; import { galleryModels, type GalleryModel } from "@/data/galleryModels";
import {
AMBIENT_LIGHT_COLOR,
LIGHTING_DEFAULTS,
SUN_LIGHT_COLOR,
} from "@/data/world/lightingConfig";
import { import {
GAME_SCENE_FALLBACK_SKY_MODEL_PATH, GAME_SCENE_FALLBACK_SKY_MODEL_PATH,
GAME_SCENE_FALLBACK_SKY_MODEL_SCALE, GAME_SCENE_FALLBACK_SKY_MODEL_SCALE,
GAME_SCENE_SKY_MODEL_PATH, GAME_SCENE_SKY_MODEL_PATH,
GAME_SCENE_SKY_MODEL_SCALE, GAME_SCENE_SKY_MODEL_SCALE,
} from "@/data/world/environmentConfig"; } from "@/data/world/environmentConfig";
import { Lighting } from "@/world/Lighting";
interface GalleryModelProps { interface GalleryModelProps {
model: GalleryModel; model: GalleryModel;
} }
interface GallerySceneProps extends GalleryModelProps { interface GallerySceneProps extends GalleryModelProps {
lighting: GalleryLightingConfig;
onTextureDiagnosticReady: (diagnostic: TextureDiagnostic) => void; 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 { interface TextureDiagnostic {
modelId: string | null; modelId: string | null;
status: "loading" | "ok" | "warning"; status: "loading" | "ok" | "warning";
@@ -71,6 +97,14 @@ const LOADING_TEXTURE_DIAGNOSTIC: TextureDiagnostic = {
summary: "Analyse des textures...", 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< class GalleryViewerErrorBoundary extends Component<
GalleryViewerErrorBoundaryProps, GalleryViewerErrorBoundaryProps,
GalleryViewerErrorBoundaryState GalleryViewerErrorBoundaryState
@@ -106,7 +140,7 @@ class GalleryViewerErrorBoundary extends Component<
function GalleryModelPreview({ function GalleryModelPreview({
model, model,
onTextureDiagnosticReady, onTextureDiagnosticReady,
}: GallerySceneProps): React.JSX.Element { }: GalleryModelPreviewProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
const { animations, scene } = useGLTF(model.path); const { animations, scene } = useGLTF(model.path);
const modelScene = useMemo(() => scene.clone(true), [scene]); const modelScene = useMemo(() => scene.clone(true), [scene]);
@@ -140,6 +174,7 @@ function GalleryModelPreview({
} }
function GalleryScene({ function GalleryScene({
lighting,
model, model,
onTextureDiagnosticReady, onTextureDiagnosticReady,
}: GallerySceneProps): React.JSX.Element { }: GallerySceneProps): React.JSX.Element {
@@ -153,7 +188,7 @@ function GalleryScene({
scale={GAME_SCENE_SKY_MODEL_SCALE} scale={GAME_SCENE_SKY_MODEL_SCALE}
unlit unlit
/> />
<Lighting /> <GalleryLighting lighting={lighting} />
<Bounds fit clip observe margin={1.35}> <Bounds fit clip observe margin={1.35}>
<Center> <Center>
<GalleryModelPreview <GalleryModelPreview
@@ -167,8 +202,28 @@ function GalleryScene({
enableDamping enableDamping
autoRotate autoRotate
autoRotateSpeed={0.5} autoRotateSpeed={0.5}
minPolarAngle={Math.PI * 0.18} minPolarAngle={0}
maxPolarAngle={Math.PI * 0.48} 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}
/> />
</> </>
); );
@@ -192,6 +247,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( function getTextureDiagnostic(
modelId: string, modelId: string,
modelScene: THREE.Object3D, modelScene: THREE.Object3D,
@@ -247,6 +358,10 @@ function getTextureDiagnostic(
export function GalleryPage(): React.JSX.Element { export function GalleryPage(): React.JSX.Element {
const [activeModelIndex, setActiveModelIndex] = useState(0); const [activeModelIndex, setActiveModelIndex] = useState(0);
const [lightPanelOpen, setLightPanelOpen] = useState(false);
const [lighting, setLighting] = useState<GalleryLightingConfig>({
...LIGHTING_DEFAULTS,
});
const [textureDiagnostic, setTextureDiagnostic] = useState<TextureDiagnostic>( const [textureDiagnostic, setTextureDiagnostic] = useState<TextureDiagnostic>(
LOADING_TEXTURE_DIAGNOSTIC, LOADING_TEXTURE_DIAGNOSTIC,
); );
@@ -269,6 +384,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 ( return (
<main className="gallery-page"> <main className="gallery-page">
<h1 className="gallery-title">GALERIE</h1> <h1 className="gallery-title">GALERIE</h1>
@@ -278,6 +407,7 @@ export function GalleryPage(): React.JSX.Element {
<Canvas camera={{ position: [3.5, 2.4, 4.5], fov: 45 }} dpr={[1, 2]}> <Canvas camera={{ position: [3.5, 2.4, 4.5], fov: 45 }} dpr={[1, 2]}>
<Suspense fallback={null}> <Suspense fallback={null}>
<GalleryScene <GalleryScene
lighting={lighting}
model={activeModel} model={activeModel}
onTextureDiagnosticReady={setTextureDiagnostic} onTextureDiagnosticReady={setTextureDiagnostic}
/> />
@@ -310,6 +440,13 @@ export function GalleryPage(): React.JSX.Element {
</nav> </nav>
<TextureStatusBadge diagnostic={activeTextureDiagnostic} /> <TextureStatusBadge diagnostic={activeTextureDiagnostic} />
<GalleryLightingPanel
lighting={lighting}
onChange={handleLightChange}
onReset={resetLighting}
onToggle={() => setLightPanelOpen((open) => !open)}
open={lightPanelOpen}
/>
</main> </main>
); );
} }