Skip to content

Commit 66a200a

Browse files
committed
validate textures at import time for GLTF/GLB models
1 parent ec10e52 commit 66a200a

8 files changed

Lines changed: 890 additions & 10 deletions

File tree

docs/solutions/feature-implementations/texture-pixel-budget-system.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ Two approaches were considered:
5858
| `components/Renderer/Metrics/Metrics.tsx` | Per-material pixel collection, info-only texture count, prominent budget warnings with specific messaging |
5959
| `components/Renderer/Metrics/Metrics.css` | `BudgetExceeded` and `BudgetWarning` highlight styles |
6060
| `lib/rpc/scene-metrics/scene-metrics.spec.ts` | Added `texturePixels` to mock data |
61+
| `components/ImportAsset/texture-validation.ts` | **NEW** — GLB/GLTF parsing, image dimension extraction, texture constraint validation, auto-resize, GLB reconstruction |
62+
| `components/ImportAsset/types.ts` | Added `textureIssues`, `textureImages` to `ModelAsset`; added `'texture'` to `ValidationError` type |
63+
| `components/ImportAsset/utils.ts` | Integrated `validateTexturesInModel` call in `getModel()` |
64+
| `components/ImportAsset/Slider/Slider.tsx` | Added `TEXTURE_WARNINGS` step, `handleFixTextures` with GLB/external image auto-resize |
65+
| `components/ImportAsset/Slider/TextureWarnings.tsx` | **NEW** — Warning UI showing per-asset texture issues with Fix/Back actions |
66+
| `components/ImportAsset/Slider/TextureWarnings.css` | **NEW** — Warning component styles |
67+
| `components/ImportAsset/texture-validation.spec.ts` | **NEW** — Unit tests for `isPowerOfTwo` and `nextPowerOfTwo` |
6168

6269
### Code Examples
6370

@@ -101,17 +108,38 @@ for (const key in material) {
101108

102109
Budget scales linearly without a ceiling. A 400-parcel scene gets 1,677 MP budget. The team needs to define a max value via profiling. Implementation is a one-liner: `Math.min(parcels * Limits.texturePixels, HARD_CAP)`.
103110

111+
### Import validation (implemented)
112+
113+
Texture validation runs at model import time. When a GLTF/GLB is dropped into the Inspector, we:
114+
115+
1. Parse the GLTF JSON (for GLB: extract JSON+BIN chunks; for GLTF: parse directly)
116+
2. Extract image dimensions using `createImageBitmap` (works for both embedded and external images)
117+
3. Validate three constraints:
118+
- **Power of two:** width and height must both be powers of 2
119+
- **Square:** width must equal height
120+
- **Layer consistency:** all texture layers in a material (baseColor, normal, emissive, occlusion, metallicRoughness) must have the same dimensions
121+
4. Show a `TextureWarnings` screen in the import dialog with three options:
122+
- **Back:** return to the upload screen
123+
- **Fix & Import:** auto-resize textures to the nearest power-of-2 square dimension
124+
125+
Auto-resize uses `OffscreenCanvas` for image processing. For GLB files, the entire binary is reconstructed with resized embedded images. For GLTF with external images, only the affected image files are replaced.
126+
127+
**Files:**
128+
129+
- `ImportAsset/texture-validation.ts` — validation logic, GLB parsing, image resizing, GLB reconstruction
130+
- `ImportAsset/Slider/TextureWarnings.tsx` — warning UI component
131+
- `ImportAsset/Slider/TextureWarnings.css` — warning styles
132+
104133
### Remaining work from shape document
105134

106-
- **Import validation:** Enforce power-of-2 textures, square dimensions (width === height), same scale across all layers of a material
107135
- **Publish-time warnings:** Creator Hub deploy flow has no pixel budget awareness yet
108136
- **Naming conventions:** `_atlas`, `_albedo`, `_normal`, `_emissive`, `_alpha` — handled by Blender plugin, not enforced in Creator Hub
109137

110138
## Prevention Strategies
111139

112140
- **When adding new metric types:** Follow the same pattern — add to `SceneMetrics` type, `Limits` enum, initial state, and `getSceneLimits`. Consider whether the metric is "info-only" or has a hard budget.
113141
- **When modifying texture collection:** The `for...in` iteration on Babylon materials with `as any` is fragile. Consider migrating to `material.getActiveTextures()` API for type safety.
114-
- **When implementing import validation:** Enforce power-of-2 and same-scale-across-layers at the point models enter the scene, not just at display time. This prevents invalid state from persisting.
142+
- **When modifying import validation:** The GLB parser in `texture-validation.ts` handles standard GLB chunk layout (JSON + BIN). Non-standard chunk ordering or multiple BIN chunks are not supported. The `rebuildGlb` function reconstructs the entire binary — changes to bufferView layout must keep 4-byte alignment.
115143
- **Texture size timing:** `texture.getSize()` can return `{width: 0, height: 0}` briefly before textures finish loading. This is a known transient edge case consistent with how all other metrics behave — not worth special-casing.
116144

117145
## References

packages/inspector/src/components/ImportAsset/Slider/Slider.tsx

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import { useCallback, useMemo, useState } from 'react';
22

3+
import type { TextureIssue, TextureImageInfo } from '../texture-validation';
34
import { Error } from '../Error';
45
import { Button } from '../../Button';
56
import type { Asset } from '../types';
7+
import { isModelAsset } from '../types';
68
import { determineAssetType, formatFileName } from '../utils';
79
import { AssetSlides } from './AssetSlides';
10+
import { TextureWarnings } from './TextureWarnings';
811
import { useSliderAssets } from './useSliderAssets';
912
import type { PropTypes, Thumbnails } from './types';
1013

1114
import './Slider.css';
1215

1316
enum ImportStep {
1417
UPLOAD = 'upload',
18+
TEXTURE_WARNINGS = 'texture_warnings',
1519
CONFIRM = 'confirm',
1620
}
1721

@@ -20,6 +24,7 @@ export function Slider({ assets, onSubmit, isNameAvailable, isImporting = false
2024
const [slide, setSlide] = useState(0);
2125
const [screenshots, setScreenshots] = useState<Thumbnails>({});
2226
const [step, setStep] = useState<ImportStep>(ImportStep.UPLOAD);
27+
const [isFixingTextures, setIsFixingTextures] = useState(false);
2328

2429
const invalidNames = useMemo(() => {
2530
const all = new Set<string>();
@@ -37,6 +42,22 @@ export function Slider({ assets, onSubmit, isNameAvailable, isImporting = false
3742
return invalid;
3843
}, [uploadedAssets, isNameAvailable]);
3944

45+
const textureIssues = useMemo(() => {
46+
const issues: { asset: Asset; issues: TextureIssue[]; images: TextureImageInfo[] }[] = [];
47+
for (const asset of uploadedAssets) {
48+
if (isModelAsset(asset) && asset.textureIssues?.length) {
49+
issues.push({
50+
asset,
51+
issues: asset.textureIssues,
52+
images: asset.textureImages ?? [],
53+
});
54+
}
55+
}
56+
return issues;
57+
}, [uploadedAssets]);
58+
59+
const hasTextureIssues = textureIssues.length > 0;
60+
4061
const handleSubmit = useCallback(() => {
4162
onSubmit(
4263
uploadedAssets.map($ => ({
@@ -47,12 +68,80 @@ export function Slider({ assets, onSubmit, isNameAvailable, isImporting = false
4768
}, [uploadedAssets, screenshots, onSubmit]);
4869

4970
const handleConfirmImport = useCallback(() => {
50-
if (invalidNames.size > 0) {
71+
if (hasTextureIssues) {
72+
setStep(ImportStep.TEXTURE_WARNINGS);
73+
} else if (invalidNames.size > 0) {
5174
setStep(ImportStep.CONFIRM);
5275
} else {
5376
handleSubmit();
5477
}
55-
}, [invalidNames, handleSubmit]);
78+
}, [hasTextureIssues, invalidNames, handleSubmit]);
79+
80+
const handleFixTextures = useCallback(async () => {
81+
setIsFixingTextures(true);
82+
try {
83+
const { fixExternalImages, fixGlbEmbeddedImages } = await import('../texture-validation');
84+
85+
const updatedAssets = await Promise.all(
86+
uploadedAssets.map(async asset => {
87+
if (
88+
!isModelAsset(asset) ||
89+
!asset.textureIssues?.length ||
90+
!asset.textureImages?.length
91+
) {
92+
return asset;
93+
}
94+
95+
const isGlb = asset.extension.toLowerCase() === 'glb';
96+
97+
if (isGlb) {
98+
const buffer = await asset.blob.arrayBuffer();
99+
const fixedBuffer = await fixGlbEmbeddedImages(
100+
buffer,
101+
asset.textureImages,
102+
asset.textureIssues,
103+
);
104+
const fixedBlob = new File([fixedBuffer], asset.blob.name, {
105+
type: asset.blob.type,
106+
});
107+
return {
108+
...asset,
109+
blob: fixedBlob,
110+
textureIssues: undefined,
111+
textureImages: undefined,
112+
};
113+
} else {
114+
const externalFiles = new Map(asset.images.map(img => [img.blob.name, img]));
115+
116+
const fixedFiles = await fixExternalImages(
117+
asset.textureImages,
118+
asset.textureIssues,
119+
externalFiles,
120+
);
121+
122+
const updatedImages = asset.images.map(img => {
123+
const fixed = fixedFiles.get(img.blob.name);
124+
return fixed ? { ...img, blob: fixed } : img;
125+
});
126+
127+
return {
128+
...asset,
129+
images: updatedImages,
130+
textureIssues: undefined,
131+
textureImages: undefined,
132+
};
133+
}
134+
}),
135+
);
136+
137+
setUploadedAssets(updatedAssets);
138+
setStep(ImportStep.UPLOAD);
139+
} catch (error) {
140+
console.error('Failed to fix textures:', error);
141+
} finally {
142+
setIsFixingTextures(false);
143+
}
144+
}, [uploadedAssets, setUploadedAssets]);
56145

57146
const handleScreenshot = useCallback(
58147
(file: Asset) => (thumbnail: string) => {
@@ -130,6 +219,14 @@ export function Slider({ assets, onSubmit, isNameAvailable, isImporting = false
130219
</Button>
131220
</div>
132221
)}
222+
{step === ImportStep.TEXTURE_WARNINGS && (
223+
<TextureWarnings
224+
textureIssues={textureIssues}
225+
isFixing={isFixingTextures}
226+
onFix={handleFixTextures}
227+
onBack={() => setStep(ImportStep.UPLOAD)}
228+
/>
229+
)}
133230
{step === ImportStep.CONFIRM && (
134231
<>
135232
<h2>Replace Assets?</h2>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
.TextureWarnings {
2+
display: flex;
3+
flex-direction: column;
4+
align-items: center;
5+
gap: 8px;
6+
max-height: 400px;
7+
}
8+
9+
.TextureWarnings-icon {
10+
color: var(--warning-main);
11+
}
12+
13+
.TextureWarnings h2 {
14+
margin: 0;
15+
}
16+
17+
.TextureWarnings-description {
18+
font-size: 12px;
19+
color: var(--base-09);
20+
text-align: center;
21+
margin: 0;
22+
line-height: 18px;
23+
}
24+
25+
.TextureWarnings-list {
26+
display: flex;
27+
flex-direction: column;
28+
gap: 8px;
29+
width: 100%;
30+
overflow-y: auto;
31+
max-height: 200px;
32+
padding: 4px 0;
33+
}
34+
35+
.TextureWarnings-asset {
36+
display: flex;
37+
flex-direction: column;
38+
gap: 4px;
39+
padding: 8px;
40+
background-color: var(--base-18);
41+
border-radius: 4px;
42+
}
43+
44+
.TextureWarnings-asset-name {
45+
font-size: 12px;
46+
font-weight: 600;
47+
color: var(--base-01);
48+
}
49+
50+
.TextureWarnings-issue {
51+
display: flex;
52+
align-items: center;
53+
gap: 6px;
54+
font-size: 11px;
55+
flex-wrap: wrap;
56+
}
57+
58+
.TextureWarnings-issue-tag {
59+
padding: 1px 6px;
60+
border-radius: 3px;
61+
background-color: rgba(255, 152, 0, 0.15);
62+
color: var(--warning-main);
63+
font-weight: 500;
64+
font-size: 10px;
65+
white-space: nowrap;
66+
}
67+
68+
.TextureWarnings-issue-message {
69+
color: var(--base-09);
70+
}
71+
72+
.TextureWarnings-issue-suggestion {
73+
color: var(--base-06);
74+
font-weight: 500;
75+
}
76+
77+
.TextureWarnings-actions {
78+
display: flex;
79+
gap: 8px;
80+
width: 100%;
81+
justify-content: flex-end;
82+
margin-top: 4px;
83+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { FiAlertTriangle as WarningIcon } from 'react-icons/fi';
2+
3+
import { Button } from '../../Button';
4+
import type { Asset } from '../types';
5+
import type { TextureIssue, TextureImageInfo } from '../texture-validation';
6+
import { formatFileName } from '../utils';
7+
8+
import './TextureWarnings.css';
9+
10+
interface TextureWarningsProps {
11+
textureIssues: { asset: Asset; issues: TextureIssue[]; images: TextureImageInfo[] }[];
12+
isFixing: boolean;
13+
onFix: () => void;
14+
onBack: () => void;
15+
}
16+
17+
const ISSUE_LABELS: Record<TextureIssue['type'], string> = {
18+
'not-power-of-two': 'Not power of two',
19+
'not-square': 'Not square',
20+
'layer-size-mismatch': 'Layer size mismatch',
21+
};
22+
23+
export function TextureWarnings({ textureIssues, isFixing, onFix, onBack }: TextureWarningsProps) {
24+
const totalIssues = textureIssues.reduce((sum, ti) => sum + ti.issues.length, 0);
25+
26+
return (
27+
<div className="TextureWarnings">
28+
<div className="TextureWarnings-icon">
29+
<WarningIcon size={40} />
30+
</div>
31+
<h2>Texture Issues Found</h2>
32+
<p className="TextureWarnings-description">
33+
{totalIssues} issue{totalIssues !== 1 ? 's' : ''} found. Textures should be power of two
34+
with equal width and height, and all layers in a material should have the same dimensions.
35+
</p>
36+
<div className="TextureWarnings-list">
37+
{textureIssues.map(({ asset, issues }) => (
38+
<div
39+
className="TextureWarnings-asset"
40+
key={asset.blob.name}
41+
>
42+
<div className="TextureWarnings-asset-name">{formatFileName(asset)}</div>
43+
{issues.map((issue, i) => (
44+
<div
45+
className="TextureWarnings-issue"
46+
key={i}
47+
>
48+
<span className="TextureWarnings-issue-tag">{ISSUE_LABELS[issue.type]}</span>
49+
<span className="TextureWarnings-issue-message">{issue.message}</span>
50+
{issue.suggestedWidth && issue.suggestedHeight && (
51+
<span className="TextureWarnings-issue-suggestion">
52+
{issue.suggestedWidth}×{issue.suggestedHeight}
53+
</span>
54+
)}
55+
</div>
56+
))}
57+
</div>
58+
))}
59+
</div>
60+
<div className="TextureWarnings-actions">
61+
<Button onClick={onBack}>Back</Button>
62+
<Button
63+
type="danger"
64+
onClick={onFix}
65+
disabled={isFixing}
66+
>
67+
{isFixing ? 'Fixing...' : 'Fix & Import'}
68+
</Button>
69+
</div>
70+
</div>
71+
);
72+
}

0 commit comments

Comments
 (0)