diff --git a/src/app/App.tsx b/src/app/App.tsx index e4636695..3886c1d0 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -210,7 +210,10 @@ import { snapPositiveSizeToGrid, snapVec3ToGrid } from "../geometry/grid-snapping"; -import { createFitToFaceBoxBrushFaceUvState } from "../geometry/box-face-uvs"; +import { + createFitToFaceBoxBrushFaceUvState, + createFitToMaterialTileBoxBrushFaceUvState +} from "../geometry/box-face-uvs"; import { DEFAULT_ENTITY_POSITION, DEFAULT_INTERACTABLE_PROMPT, @@ -333,6 +336,8 @@ import { } from "../controls/control-surface"; import { STARTER_MATERIAL_LIBRARY, + getStarterMaterialPreviewUrl, + getStarterMaterialTileSizeMeters, type MaterialDef } from "../materials/starter-material-library"; import { RunnerCanvas } from "../runner-web/RunnerCanvas"; @@ -1534,32 +1539,13 @@ function sortDocumentMaterials( } function getMaterialPreviewStyle(material: MaterialDef): CSSProperties { - switch (material.pattern) { - case "grid": - return { - backgroundColor: material.baseColorHex, - backgroundImage: `linear-gradient(${material.accentColorHex} 2px, transparent 2px), linear-gradient(90deg, ${material.accentColorHex} 2px, transparent 2px)`, - backgroundSize: "18px 18px" - }; - case "checker": - return { - backgroundColor: material.baseColorHex, - backgroundImage: `linear-gradient(45deg, ${material.accentColorHex} 25%, transparent 25%, transparent 75%, ${material.accentColorHex} 75%, ${material.accentColorHex}), linear-gradient(45deg, ${material.accentColorHex} 25%, transparent 25%, transparent 75%, ${material.accentColorHex} 75%, ${material.accentColorHex})`, - backgroundPosition: "0 0, 9px 9px", - backgroundSize: "18px 18px" - }; - case "stripes": - return { - backgroundColor: material.baseColorHex, - backgroundImage: `repeating-linear-gradient(135deg, ${material.accentColorHex} 0 9px, transparent 9px 18px)` - }; - case "diamond": - return { - backgroundColor: material.baseColorHex, - backgroundImage: `linear-gradient(45deg, ${material.accentColorHex} 12%, transparent 12%, transparent 88%, ${material.accentColorHex} 88%), linear-gradient(-45deg, ${material.accentColorHex} 12%, transparent 12%, transparent 88%, ${material.accentColorHex} 88%)`, - backgroundSize: "22px 22px" - }; - } + return { + backgroundColor: material.swatchColorHex, + backgroundImage: `url("${getStarterMaterialPreviewUrl(material)}")`, + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + backgroundSize: "cover" + }; } function rotateQuarterTurns( @@ -10033,7 +10019,13 @@ export function App({ store, initialStatusMessage }: AppProps) { } applyFaceUvState( - createFitToFaceBoxBrushFaceUvState(selectedBrush, selectedFaceId), + selectedFaceMaterial === undefined || selectedFaceMaterial === null + ? createFitToFaceBoxBrushFaceUvState(selectedBrush, selectedFaceId) + : createFitToMaterialTileBoxBrushFaceUvState( + selectedBrush, + selectedFaceId, + getStarterMaterialTileSizeMeters(selectedFaceMaterial) + ), "Fit face UV to face", "Fit the selected face UVs to the face bounds." ); diff --git a/src/document/scene-document.ts b/src/document/scene-document.ts index 2c466df3..0f21b41c 100644 --- a/src/document/scene-document.ts +++ b/src/document/scene-document.ts @@ -32,7 +32,9 @@ import { type ProjectSequenceLibrary } from "../sequencer/project-sequences"; -export const SCENE_DOCUMENT_VERSION = 58 as const; +export const SCENE_DOCUMENT_VERSION = 59 as const; +export const STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION = + 59 as const; export const SCENE_TRANSITION_SEQUENCE_EFFECTS_SCENE_DOCUMENT_VERSION = 58 as const; export const PROJECT_SEQUENCE_UNIFIED_VISIBILITY_SCENE_DOCUMENT_VERSION = diff --git a/src/geometry/box-face-uvs.ts b/src/geometry/box-face-uvs.ts index 9106962c..1dfa6212 100644 --- a/src/geometry/box-face-uvs.ts +++ b/src/geometry/box-face-uvs.ts @@ -43,6 +43,22 @@ export function createFitToFaceBoxBrushFaceUvState(brush: BoxBrush, faceId: BoxF }; } +export function createFitToMaterialTileBoxBrushFaceUvState( + brush: BoxBrush, + faceId: BoxFaceId, + tileSize: Vec2 +): FaceUvState { + const faceSize = getBoxBrushFaceSize(brush, faceId); + + return { + ...createDefaultFaceUvState(), + scale: { + x: tileSize.x / faceSize.x, + y: tileSize.y / faceSize.y + } + }; +} + export function projectBoxFaceVertexToUv(vertexPosition: Vec3, brush: BoxBrushUvProjectionSource, faceId: BoxFaceId): Vec2 { const halfSize = { x: brush.size.x * 0.5, diff --git a/src/materials/starter-material-textures.ts b/src/materials/starter-material-textures.ts index 2c08a3c5..afd6dea6 100644 --- a/src/materials/starter-material-textures.ts +++ b/src/materials/starter-material-textures.ts @@ -1,89 +1,161 @@ -import { CanvasTexture, RepeatWrapping, SRGBColorSpace } from "three"; +import { + RepeatWrapping, + SRGBColorSpace, + Texture, + TextureLoader +} from "three"; -import type { MaterialDef } from "./starter-material-library"; +import { + getStarterMaterialBaseColorUrl, + getStarterMaterialMetallicUrl, + getStarterMaterialNormalUrl, + getStarterMaterialRoughnessUrl, + getStarterMaterialSpecularUrl, + getStarterMaterialTextureRepeat, + type MaterialDef +} from "./starter-material-library"; + +export interface StarterMaterialTextureSet { + baseColor: Texture; + normal: Texture; + roughness: Texture; + metallic: Texture | null; + specular: Texture | null; +} export function createStarterMaterialSignature(material: MaterialDef): string { - return `${material.baseColorHex}|${material.accentColorHex}|${material.pattern}`; + return [ + material.assetFolder, + material.workflow, + material.previewImageName, + material.sizeCm.width, + material.sizeCm.height, + material.swatchColorHex + ].join("|"); } -function fillMaterialPattern(context: CanvasRenderingContext2D, material: MaterialDef, size: number) { - context.fillStyle = material.baseColorHex; - context.fillRect(0, 0, size, size); - context.strokeStyle = material.accentColorHex; - context.fillStyle = material.accentColorHex; +function isPowerOfTwo(value: number): boolean { + return value > 0 && (value & (value - 1)) === 0; +} - switch (material.pattern) { - case "grid": - context.lineWidth = Math.max(2, size / 32); +function floorPowerOfTwo(value: number): number { + return 2 ** Math.max(0, Math.floor(Math.log2(Math.max(1, value)))); +} - for (let offset = 0; offset <= size; offset += size / 4) { - context.beginPath(); - context.moveTo(offset, 0); - context.lineTo(offset, size); - context.stroke(); - - context.beginPath(); - context.moveTo(0, offset); - context.lineTo(size, offset); - context.stroke(); - } - break; - case "checker": { - const checkerSize = size / 4; - - for (let row = 0; row < 4; row += 1) { - for (let column = 0; column < 4; column += 1) { - if ((row + column) % 2 === 0) { - context.fillRect(column * checkerSize, row * checkerSize, checkerSize, checkerSize); - } - } - } - break; - } - case "stripes": - context.lineWidth = size / 6; - - for (let offset = -size; offset <= size * 2; offset += size / 3) { - context.beginPath(); - context.moveTo(offset, size); - context.lineTo(offset + size, 0); - context.stroke(); - } - break; - case "diamond": - context.lineWidth = Math.max(2, size / 28); - - for (let offset = -size; offset <= size; offset += size / 3) { - context.beginPath(); - context.moveTo(size * 0.5, offset); - context.lineTo(size - offset, size * 0.5); - context.lineTo(size * 0.5, size - offset); - context.lineTo(-offset, size * 0.5); - context.closePath(); - context.stroke(); - } - break; +function createRepeatableTextureImage( + image: { width: number; height: number } +): { width: number; height: number } | HTMLCanvasElement { + if (isPowerOfTwo(image.width) && isPowerOfTwo(image.height)) { + return image; } -} -export function createStarterMaterialTexture(material: MaterialDef, size = 128): CanvasTexture { const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; + canvas.width = floorPowerOfTwo(image.width); + canvas.height = floorPowerOfTwo(image.height); const context = canvas.getContext("2d"); if (context === null) { - throw new Error("2D canvas context is unavailable for starter material texture generation."); + throw new Error("2D canvas context is unavailable for starter material texture conversion."); } - fillMaterialPattern(context, material, size); + context.drawImage( + image as CanvasImageSource, + 0, + 0, + canvas.width, + canvas.height + ); - const texture = new CanvasTexture(canvas); + return canvas; +} + +function configureTexture( + texture: Texture, + material: MaterialDef, + options: { colorSpace?: typeof SRGBColorSpace | null } +) { + const repeat = getStarterMaterialTextureRepeat(material); texture.wrapS = RepeatWrapping; texture.wrapT = RepeatWrapping; - texture.colorSpace = SRGBColorSpace; - texture.needsUpdate = true; + texture.repeat.set(repeat.x, repeat.y); + texture.colorSpace = options.colorSpace ?? texture.colorSpace; +} + +function loadMaterialTexture( + loader: TextureLoader, + url: string, + material: MaterialDef, + options: { colorSpace?: typeof SRGBColorSpace | null } +): Texture { + const texture = loader.load(url, (loadedTexture) => { + loadedTexture.image = createRepeatableTextureImage( + loadedTexture.image as { width: number; height: number } + ); + configureTexture(loadedTexture, material, options); + loadedTexture.needsUpdate = true; + }); + + configureTexture(texture, material, options); return texture; } + +export function createStarterMaterialTextureSet( + material: MaterialDef, + loader: TextureLoader = new TextureLoader() +): StarterMaterialTextureSet { + const metallicUrl = getStarterMaterialMetallicUrl(material); + const specularUrl = getStarterMaterialSpecularUrl(material); + + return { + baseColor: loadMaterialTexture(loader, getStarterMaterialBaseColorUrl(material), material, { + colorSpace: SRGBColorSpace + }), + normal: loadMaterialTexture(loader, getStarterMaterialNormalUrl(material), material, { + colorSpace: null + }), + roughness: loadMaterialTexture( + loader, + getStarterMaterialRoughnessUrl(material), + material, + { + colorSpace: null + } + ), + metallic: + metallicUrl === null + ? null + : loadMaterialTexture(loader, metallicUrl, material, { + colorSpace: null + }), + specular: + specularUrl === null + ? null + : loadMaterialTexture(loader, specularUrl, material, { + colorSpace: null + }) + }; +} + +export function disposeStarterMaterialTextureSet( + textureSet: StarterMaterialTextureSet +): void { + const textures = new Set([ + textureSet.baseColor, + textureSet.normal, + textureSet.roughness + ]); + + if (textureSet.metallic !== null) { + textures.add(textureSet.metallic); + } + + if (textureSet.specular !== null) { + textures.add(textureSet.specular); + } + + for (const texture of textures) { + texture.dispose(); + } +} diff --git a/src/runtime-three/runtime-host.ts b/src/runtime-three/runtime-host.ts index 067607aa..aabeadca 100644 --- a/src/runtime-three/runtime-host.ts +++ b/src/runtime-three/runtime-host.ts @@ -5,6 +5,7 @@ import { BufferGeometry, BoxGeometry, CapsuleGeometry, + Color, ConeGeometry, DirectionalLight, Euler, @@ -16,6 +17,7 @@ import { Material, Mesh, MeshBasicMaterial, + MeshPhysicalMaterial, MeshStandardMaterial, PerspectiveCamera, PointLight, @@ -24,6 +26,7 @@ import { ShaderMaterial, Vector3, SpotLight, + TextureLoader, WebGLRenderTarget, WebGLRenderer } from "three"; @@ -55,8 +58,11 @@ import { import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh"; import { createStarterMaterialSignature, - createStarterMaterialTexture + createStarterMaterialTextureSet, + disposeStarterMaterialTextureSet, + type StarterMaterialTextureSet } from "../materials/starter-material-textures"; +import type { MaterialDef } from "../materials/starter-material-library"; import { applyAdvancedRenderingLightShadowFlags, applyAdvancedRenderingRenderableShadowFlags, @@ -146,7 +152,7 @@ import { resolvePlayerStartPauseInput } from "./player-input-bindings"; interface CachedMaterialTexture { signature: string; - texture: ReturnType; + textureSet: StarterMaterialTextureSet; } function isEditableEventTarget(target: EventTarget | null): boolean { @@ -266,6 +272,7 @@ export class RuntimeHost { string, CachedMaterialTexture >(); + private readonly materialTextureLoader = new TextureLoader(); private readonly animationMixers = new Map(); private readonly instanceAnimationClips = new Map(); private readonly controllerContext: RuntimeControllerContext; @@ -744,7 +751,7 @@ export class RuntimeHost { } for (const cachedTexture of this.materialTextureCache.values()) { - cachedTexture.texture.dispose(); + disposeStarterMaterialTextureSet(cachedTexture.textureSet); } this.materialTextureCache.clear(); @@ -2275,11 +2282,18 @@ export class RuntimeHost { return faceMaterial; } - const faceMaterial = new MeshStandardMaterial({ + const textureSet = this.getOrCreateTextureSet(material); + const faceMaterial = new MeshPhysicalMaterial({ color: 0xffffff, - map: this.getOrCreateTexture(material), - roughness: 0.92, - metalness: 0.02 + map: textureSet.baseColor, + normalMap: textureSet.normal, + roughnessMap: textureSet.roughness, + roughness: 1, + metalnessMap: textureSet.metallic, + metalness: textureSet.metallic === null ? 0.03 : 1, + specularColorMap: textureSet.specular, + specularColor: new Color(0xffffff), + specularIntensity: textureSet.specular === null ? 0.2 : 1 }); if ( @@ -2566,25 +2580,30 @@ export class RuntimeHost { } } - private getOrCreateTexture( + private getOrCreateTextureSet( material: NonNullable ) { const signature = createStarterMaterialSignature(material); const cachedTexture = this.materialTextureCache.get(material.id); if (cachedTexture !== undefined && cachedTexture.signature === signature) { - return cachedTexture.texture; + return cachedTexture.textureSet; } - cachedTexture?.texture.dispose(); + if (cachedTexture !== undefined) { + disposeStarterMaterialTextureSet(cachedTexture.textureSet); + } - const texture = createStarterMaterialTexture(material); + const textureSet = createStarterMaterialTextureSet( + material, + this.materialTextureLoader + ); this.materialTextureCache.set(material.id, { signature, - texture + textureSet }); - return texture; + return textureSet; } private clearLocalLights() { diff --git a/src/viewport-three/viewport-host.ts b/src/viewport-three/viewport-host.ts index 4d673906..9a772322 100644 --- a/src/viewport-three/viewport-host.ts +++ b/src/viewport-three/viewport-host.ts @@ -3,8 +3,8 @@ import { AxesHelper, BufferGeometry, BoxGeometry, - CanvasTexture, CapsuleGeometry, + Color, ConeGeometry, CylinderGeometry, DirectionalLight, @@ -19,6 +19,7 @@ import { Matrix4, Mesh, MeshBasicMaterial, + MeshPhysicalMaterial, MeshStandardMaterial, Object3D, OrthographicCamera, @@ -33,6 +34,7 @@ import { Spherical, TorusGeometry, SpotLight, + TextureLoader, Vector2, Vector3, WebGLRenderTarget, @@ -160,7 +162,9 @@ import { buildGeneratedModelCollider } from "../geometry/model-instance-collider import { DEFAULT_GRID_SIZE, snapValueToGrid } from "../geometry/grid-snapping"; import { createStarterMaterialSignature, - createStarterMaterialTexture + createStarterMaterialTextureSet, + disposeStarterMaterialTextureSet, + type StarterMaterialTextureSet } from "../materials/starter-material-textures"; import type { MaterialDef } from "../materials/starter-material-library"; import { @@ -320,7 +324,7 @@ const VIEWPORT_GRID_VISUAL_DIVISIONS = 400; interface CachedMaterialTexture { signature: string; - texture: CanvasTexture; + textureSet: StarterMaterialTextureSet; } interface EntityRenderObjects { @@ -405,6 +409,7 @@ export class ViewportHost { string, CachedMaterialTexture >(); + private readonly materialTextureLoader = new TextureLoader(); private currentDocument: SceneDocument | null = null; private currentWorld: WorldSettings | null = null; private currentSimulationScene: RuntimeSceneDefinition | null = null; @@ -1020,7 +1025,7 @@ export class ViewportHost { this.renderer.autoClear = true; for (const cachedTexture of this.materialTextureCache.values()) { - cachedTexture.texture.dispose(); + disposeStarterMaterialTextureSet(cachedTexture.textureSet); } this.materialTextureCache.clear(); @@ -4671,6 +4676,21 @@ export class ViewportHost { return "none"; } + private getMaterialSwatchColorHex( + material: MaterialDef, + highlightState: "none" | "hovered" | "selected" + ): number { + const swatchColor = new Color(material.swatchColorHex); + + if (highlightState === "selected") { + swatchColor.lerp(new Color(SELECTED_FACE_FALLBACK_COLOR), 0.42); + } else if (highlightState === "hovered") { + swatchColor.lerp(new Color(HOVERED_FACE_FALLBACK_COLOR), 0.28); + } + + return swatchColor.getHex(); + } + private createFaceMaterial( brush: BoxBrush, faceId: BoxFaceId, @@ -4822,9 +4842,7 @@ export class ViewportHost { : hoveredFace ? HOVERED_FACE_FALLBACK_COLOR : FALLBACK_FACE_COLOR - : emphasizedFace - ? material.accentColorHex - : material.baseColorHex; + : this.getMaterialSwatchColorHex(material, highlightState); return new MeshBasicMaterial({ color: colorHex, @@ -4842,9 +4860,7 @@ export class ViewportHost { : hoveredFace ? HOVERED_FACE_FALLBACK_COLOR : FALLBACK_FACE_COLOR - : emphasizedFace - ? material.accentColorHex - : material.baseColorHex; + : this.getMaterialSwatchColorHex(material, highlightState); return new MeshBasicMaterial({ color: colorHex, @@ -4885,17 +4901,24 @@ export class ViewportHost { return faceMaterial; } - const faceMaterial = new MeshStandardMaterial({ + const textureSet = this.getOrCreateTextureSet(material); + const faceMaterial = new MeshPhysicalMaterial({ color: 0xffffff, - map: this.getOrCreateTexture(material), + map: textureSet.baseColor, + normalMap: textureSet.normal, + roughnessMap: textureSet.roughness, emissive: selectedFace ? SELECTED_FACE_EMISSIVE : hoveredFace ? HOVERED_FACE_EMISSIVE : 0x000000, emissiveIntensity: selectedFace ? 0.32 : hoveredFace ? 0.18 : 0, - roughness: 0.92, - metalness: 0.02 + roughness: 1, + metalnessMap: textureSet.metallic, + metalness: textureSet.metallic === null ? 0.03 : 1, + specularColorMap: textureSet.specular, + specularColor: new Color(0xffffff), + specularIntensity: textureSet.specular === null ? 0.2 : 1 }); if ( @@ -5150,24 +5173,29 @@ export class ViewportHost { } } - private getOrCreateTexture(material: MaterialDef): CanvasTexture { + private getOrCreateTextureSet(material: MaterialDef) { const signature = createStarterMaterialSignature(material); const cachedTexture = this.materialTextureCache.get(material.id); if (cachedTexture !== undefined && cachedTexture.signature === signature) { - return cachedTexture.texture; + return cachedTexture.textureSet; } - cachedTexture?.texture.dispose(); + if (cachedTexture !== undefined) { + disposeStarterMaterialTextureSet(cachedTexture.textureSet); + } - const texture = createStarterMaterialTexture(material); + const textureSet = createStarterMaterialTextureSet( + material, + this.materialTextureLoader + ); this.materialTextureCache.set(material.id, { signature, - texture + textureSet }); - return texture; + return textureSet; } private collectViewportWaterContactPatches(