Compare commits
3 Commits
6a412c7b00
...
cf71148935
| Author | SHA1 | Date | |
|---|---|---|---|
| cf71148935 | |||
| 1b2241df49 | |||
| d7351e5f37 |
@@ -4,14 +4,15 @@ La galerie est disponible sur `/gallery`. Elle permet de parcourir les modèles
|
|||||||
|
|
||||||
## Objectif
|
## 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
|
## Utilisation
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -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`.
|
- Le viewer utilise `@react-three/fiber` et `@react-three/drei`.
|
||||||
- `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.
|
- `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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ interface SkyModelProps {
|
|||||||
modelPath: string;
|
modelPath: string;
|
||||||
fallbackModelPath?: string | undefined;
|
fallbackModelPath?: string | undefined;
|
||||||
fallbackScale?: number | undefined;
|
fallbackScale?: number | undefined;
|
||||||
|
materialSide?: THREE.Side | undefined;
|
||||||
scale?: number | undefined;
|
scale?: number | undefined;
|
||||||
|
unlit?: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SkyModelContentProps {
|
interface SkyModelContentProps {
|
||||||
|
materialSide: THREE.Side;
|
||||||
modelPath: string;
|
modelPath: string;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
unlit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SkyModelErrorBoundaryProps {
|
interface SkyModelErrorBoundaryProps {
|
||||||
@@ -54,23 +58,37 @@ class SkyModelErrorBoundary extends Component<
|
|||||||
export function SkyModel({
|
export function SkyModel({
|
||||||
fallbackModelPath,
|
fallbackModelPath,
|
||||||
fallbackScale = SKY_MODEL_SCALE,
|
fallbackScale = SKY_MODEL_SCALE,
|
||||||
|
materialSide = THREE.BackSide,
|
||||||
modelPath,
|
modelPath,
|
||||||
scale = SKY_MODEL_SCALE,
|
scale = SKY_MODEL_SCALE,
|
||||||
|
unlit = false,
|
||||||
}: SkyModelProps): React.JSX.Element {
|
}: SkyModelProps): React.JSX.Element {
|
||||||
const fallback = fallbackModelPath ? (
|
const fallback = fallbackModelPath ? (
|
||||||
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} />
|
<SkyModelContent
|
||||||
|
materialSide={materialSide}
|
||||||
|
modelPath={fallbackModelPath}
|
||||||
|
scale={fallbackScale}
|
||||||
|
unlit={unlit}
|
||||||
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
|
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
|
||||||
<SkyModelContent modelPath={modelPath} scale={scale} />
|
<SkyModelContent
|
||||||
|
materialSide={materialSide}
|
||||||
|
modelPath={modelPath}
|
||||||
|
scale={scale}
|
||||||
|
unlit={unlit}
|
||||||
|
/>
|
||||||
</SkyModelErrorBoundary>
|
</SkyModelErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkyModelContent({
|
function SkyModelContent({
|
||||||
|
materialSide,
|
||||||
modelPath,
|
modelPath,
|
||||||
scale,
|
scale,
|
||||||
|
unlit,
|
||||||
}: SkyModelContentProps): React.JSX.Element {
|
}: SkyModelContentProps): React.JSX.Element {
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
@@ -78,7 +96,10 @@ function SkyModelContent({
|
|||||||
scope: "SkyModel",
|
scope: "SkyModel",
|
||||||
scale,
|
scale,
|
||||||
});
|
});
|
||||||
const model = useMemo(() => createSkyModel(scene), [scene]);
|
const model = useMemo(
|
||||||
|
() => createSkyModel(scene, materialSide, unlit),
|
||||||
|
[materialSide, scene, unlit],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
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);
|
const model = scene.clone(true);
|
||||||
|
|
||||||
model.traverse((object) => {
|
model.traverse((object) => {
|
||||||
@@ -112,20 +137,42 @@ function createSkyModel(scene: THREE.Object3D): THREE.Object3D {
|
|||||||
if (!(object instanceof THREE.Mesh)) return;
|
if (!(object instanceof THREE.Mesh)) return;
|
||||||
|
|
||||||
object.material = Array.isArray(object.material)
|
object.material = Array.isArray(object.material)
|
||||||
? object.material.map(createSkyMaterial)
|
? object.material.map((material) =>
|
||||||
: createSkyMaterial(object.material);
|
createSkyMaterial(material, materialSide, unlit),
|
||||||
|
)
|
||||||
|
: createSkyMaterial(object.material, materialSide, unlit);
|
||||||
});
|
});
|
||||||
|
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSkyMaterial<T extends THREE.Material>(material: T): T {
|
function createSkyMaterial<T extends THREE.Material>(
|
||||||
const skyMaterial = material.clone();
|
material: T,
|
||||||
skyMaterial.side = THREE.BackSide;
|
materialSide: THREE.Side,
|
||||||
|
unlit: boolean,
|
||||||
|
): THREE.Material {
|
||||||
|
const skyMaterial = unlit
|
||||||
|
? createUnlitSkyMaterial(material)
|
||||||
|
: material.clone();
|
||||||
|
skyMaterial.side = materialSide;
|
||||||
skyMaterial.depthTest = false;
|
skyMaterial.depthTest = false;
|
||||||
skyMaterial.depthWrite = 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 {
|
function disposeSkyModelMaterials(model: THREE.Object3D): void {
|
||||||
|
|||||||
+126
-23
@@ -36,8 +36,9 @@ canvas {
|
|||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #05070c;
|
background: #050505;
|
||||||
color: #f8fafc;
|
color: #f4efe7;
|
||||||
|
font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-title {
|
.gallery-title {
|
||||||
@@ -46,7 +47,7 @@ canvas {
|
|||||||
right: clamp(18px, 3vw, 38px);
|
right: clamp(18px, 3vw, 38px);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: rgba(248, 250, 252, 0.92);
|
color: #f4efe7;
|
||||||
font-size: clamp(18px, 2vw, 26px);
|
font-size: clamp(18px, 2vw, 26px);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.32em;
|
letter-spacing: 0.32em;
|
||||||
@@ -78,12 +79,11 @@ canvas {
|
|||||||
grid-template-columns: 54px minmax(190px, 340px) 54px;
|
grid-template-columns: 54px minmax(190px, 340px) 54px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(248, 250, 252, 0.18);
|
border: 2px solid #d8d0c4;
|
||||||
border-radius: 999px;
|
border-radius: 0;
|
||||||
background: rgba(3, 7, 18, 0.72);
|
background: #050505;
|
||||||
box-shadow: 0 18px 52px rgba(0, 0, 0, 0.32);
|
box-shadow: none;
|
||||||
transform: translateX(50%);
|
transform: translateX(50%);
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-bottom-bar button {
|
.gallery-bottom-bar button {
|
||||||
@@ -93,7 +93,7 @@ canvas {
|
|||||||
height: 54px;
|
height: 54px;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: rgba(248, 250, 252, 0.82);
|
color: #f4efe7;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
background 160ms ease,
|
background 160ms ease,
|
||||||
@@ -102,8 +102,8 @@ canvas {
|
|||||||
|
|
||||||
.gallery-bottom-bar button:hover,
|
.gallery-bottom-bar button:hover,
|
||||||
.gallery-bottom-bar button:focus-visible {
|
.gallery-bottom-bar button:focus-visible {
|
||||||
background: rgba(248, 250, 252, 0.1);
|
background: #f4efe7;
|
||||||
color: #f8fafc;
|
color: #050505;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,15 +112,15 @@ canvas {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
min-height: 54px;
|
min-height: 54px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
border-right: 1px solid rgba(248, 250, 252, 0.14);
|
border-right: 2px solid #d8d0c4;
|
||||||
border-left: 1px solid rgba(248, 250, 252, 0.14);
|
border-left: 2px solid #d8d0c4;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-model-info span {
|
.gallery-model-info span {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #f8fafc;
|
color: #f4efe7;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
@@ -131,7 +131,7 @@ canvas {
|
|||||||
|
|
||||||
.gallery-model-info small {
|
.gallery-model-info small {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
color: rgba(203, 213, 225, 0.62);
|
color: #a9a196;
|
||||||
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -147,26 +147,125 @@ canvas {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-width: min(320px, calc(100vw - 36px));
|
max-width: min(320px, calc(100vw - 36px));
|
||||||
padding: 10px 13px;
|
padding: 10px 13px;
|
||||||
border: 1px solid rgba(248, 250, 252, 0.14);
|
border: 2px solid #d8d0c4;
|
||||||
border-radius: 999px;
|
border-radius: 0;
|
||||||
background: rgba(3, 7, 18, 0.58);
|
background: #050505;
|
||||||
color: rgba(226, 232, 240, 0.86);
|
color: #d8d0c4;
|
||||||
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-texture-status--ok {
|
.gallery-texture-status--ok {
|
||||||
color: #bbf7d0;
|
color: #d8d0c4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-texture-status--warning {
|
.gallery-texture-status--warning {
|
||||||
color: #fde68a;
|
color: #f4efe7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-texture-status--loading {
|
.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) {
|
@media (max-width: 720px) {
|
||||||
@@ -196,6 +295,10 @@ canvas {
|
|||||||
left: auto;
|
left: auto;
|
||||||
transform: translateX(50%);
|
transform: translateX(50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gallery-light-panel {
|
||||||
|
top: 78px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Docs layout */
|
/* Docs layout */
|
||||||
|
|||||||
+197
-7
@@ -19,26 +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 {
|
||||||
|
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 { galleryModels, type GalleryModel } from "@/data/galleryModels";
|
|
||||||
|
|
||||||
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";
|
||||||
@@ -70,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
|
||||||
@@ -105,12 +140,18 @@ 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(() => createGalleryModelScene(scene), [scene]);
|
||||||
const { actions } = useAnimations(animations, groupRef);
|
const { actions } = useAnimations(animations, groupRef);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
disposeGalleryModelMaterials(modelScene);
|
||||||
|
};
|
||||||
|
}, [modelScene]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onTextureDiagnosticReady(getTextureDiagnostic(model.id, modelScene));
|
onTextureDiagnosticReady(getTextureDiagnostic(model.id, modelScene));
|
||||||
}, [model.id, modelScene, onTextureDiagnosticReady]);
|
}, [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({
|
function GalleryScene({
|
||||||
|
lighting,
|
||||||
model,
|
model,
|
||||||
onTextureDiagnosticReady,
|
onTextureDiagnosticReady,
|
||||||
}: GallerySceneProps): React.JSX.Element {
|
}: GallerySceneProps): React.JSX.Element {
|
||||||
@@ -147,11 +234,12 @@ function GalleryScene({
|
|||||||
<SkyModel
|
<SkyModel
|
||||||
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
|
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
|
||||||
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
|
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
|
||||||
|
materialSide={THREE.DoubleSide}
|
||||||
modelPath={GAME_SCENE_SKY_MODEL_PATH}
|
modelPath={GAME_SCENE_SKY_MODEL_PATH}
|
||||||
scale={GAME_SCENE_SKY_MODEL_SCALE}
|
scale={GAME_SCENE_SKY_MODEL_SCALE}
|
||||||
|
unlit
|
||||||
/>
|
/>
|
||||||
<ambientLight intensity={0.75} />
|
<GalleryLighting lighting={lighting} />
|
||||||
<directionalLight position={[6, 8, 4]} intensity={2.1} />
|
|
||||||
<Bounds fit clip observe margin={1.35}>
|
<Bounds fit clip observe margin={1.35}>
|
||||||
<Center>
|
<Center>
|
||||||
<GalleryModelPreview
|
<GalleryModelPreview
|
||||||
@@ -165,8 +253,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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -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(
|
function getTextureDiagnostic(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
modelScene: THREE.Object3D,
|
modelScene: THREE.Object3D,
|
||||||
@@ -245,6 +409,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,
|
||||||
);
|
);
|
||||||
@@ -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 (
|
return (
|
||||||
<main className="gallery-page">
|
<main className="gallery-page">
|
||||||
<h1 className="gallery-title">GALERIE</h1>
|
<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]}>
|
<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}
|
||||||
/>
|
/>
|
||||||
@@ -308,6 +491,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user