Browse Source

feat(usePBRTexture): add new composable for loading PBR textures

- Implement `usePBRTexture` composable for simplified PBR texture management
- Support concurrent texture loading with async/await and reactive states
- Add comprehensive documentation with usage examples and API reference
- Create playground examples demonstrating sync and async texture loading
- Implement robust error handling and loading state tracking
- Enhance type safety with detailed TypeScript interfaces
alvarosabu 6 months ago
parent
commit
b41e3401ee

+ 1 - 0
docs/.vitepress/config/en.ts

@@ -42,6 +42,7 @@ export const enConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
         link: '/api/composables',
         items: [
           { text: 'useTexture', link: '/composables/use-texture' },
+          { text: 'usePBRTexture', link: '/composables/use-pbr-texture' },
         ],
       },
       {

+ 184 - 0
docs/composables/use-pbr-texture.md

@@ -0,0 +1,184 @@
+# usePBRTexture
+
+![PBR Maps explained](https://learnopengl.com/img/pbr/textures.png)
+
+A Vue composable for loading and managing [PBR (Physically Based Rendering)](https://learnopengl.com/PBR/Theory) texture sets in TresJS applications. This composable provides a convenient way to load multiple PBR textures concurrently and manage them as a cohesive set.
+
+## Features
+
+- 🎨 Simplified PBR texture management
+- ⚡️ Concurrent texture loading
+- 🔄 Reactive texture references
+- ⏳ Loading state tracking
+- ❌ Error handling
+- ⏱️ Async/await support with Suspense
+
+## Basic Usage
+
+The composable can be used in two ways: with or without `await`.
+
+### With await (Recommended)
+
+```ts
+import { usePBRTexture } from '@tresjs/core'
+
+// Wait for all textures to fully load
+const { data: textures } = await usePBRTexture({
+  map: 'textures/wood/albedo.jpg',
+  normalMap: 'textures/wood/normal.jpg',
+  roughnessMap: 'textures/wood/roughness.jpg'
+})
+
+// All textures are fully loaded and ready to use
+console.log(textures.value.map) // Texture object with loaded image
+```
+
+### Without await
+
+```ts
+import { usePBRTexture } from '@tresjs/core'
+import { watch } from 'vue'
+
+// Textures will start loading immediately
+const { data: textures, isLoading } = usePBRTexture({
+  map: 'textures/wood/albedo.jpg',
+  normalMap: 'textures/wood/normal.jpg',
+  roughnessMap: 'textures/wood/roughness.jpg'
+})
+
+// Watch for loading completion
+watch(isLoading, (loading) => {
+  if (!loading) {
+    console.log('All textures loaded:', textures.value)
+  }
+})
+```
+
+### Using with TresMeshStandardMaterial
+
+The textures can be directly bound to a TresMeshStandardMaterial. When using without await, make sure to handle the loading state:
+
+```vue
+<script setup>
+import { usePBRTexture } from '@tresjs/core'
+
+const { data: textures, isLoading } = usePBRTexture({
+  map: 'textures/wood/albedo.jpg',
+  normalMap: 'textures/wood/normal.jpg',
+  roughnessMap: 'textures/wood/roughness.jpg'
+})
+</script>
+
+<template>
+  <TresMesh v-if="!isLoading">
+    <TresBoxGeometry />
+    <TresMeshStandardMaterial v-bind="textures.value" />
+  </TresMesh>
+</template>
+```
+
+## Advanced Usage
+
+### With Loading States
+
+```vue
+<script setup>
+const { data: textures, isLoading, error } = usePBRTexture({
+  map: 'textures/metal/albedo.jpg',
+  normalMap: 'textures/metal/normal.jpg',
+  roughnessMap: 'textures/metal/roughness.jpg'
+})
+</script>
+
+<template>
+  <div v-if="isLoading">Loading textures...</div>
+  <div v-else-if="error">Error: {{ error.message }}</div>
+  <TresMesh v-else>
+    <TresBoxGeometry />
+    <TresMeshStandardMaterial v-bind="textures.value" />
+  </TresMesh>
+</template>
+```
+
+### With Suspense
+
+When using with Suspense, you must use the await pattern:
+
+```vue
+<template>
+  <Suspense>
+    <PBRMaterial />
+    <template #fallback>
+      <div>Loading material...</div>
+    </template>
+  </Suspense>
+</template>
+```
+
+```vue
+<!-- PBRMaterial.vue -->
+<script setup>
+const { data: textures } = await usePBRTexture({
+  map: 'textures/metal/albedo.jpg',
+  normalMap: 'textures/metal/normal.jpg',
+  // ...other textures
+})
+</script>
+
+<template>
+  <TresMeshStandardMaterial v-bind="textures.value" />
+</template>
+```
+
+## API Reference
+
+### Options
+
+The `usePBRTexture` composable accepts an options object with the following properties:
+
+| Property | Type | Description |
+| --- | --- | --- |
+| `map` | `string` | Path to the base color/albedo texture |
+| `normalMap` | `string` | Path to the normal map texture |
+| `roughnessMap` | `string` | Path to the roughness map texture |
+| `metalnessMap` | `string` | Path to the metalness map texture |
+| `aoMap` | `string` | Path to the ambient occlusion map texture |
+| `displacementMap` | `string` | Path to the height/displacement map texture |
+| `emissiveMap` | `string` | Path to the emissive map texture |
+
+All properties are optional, allowing you to load only the textures you need.
+
+### Returns
+
+The composable returns an object with the following properties:
+
+| Property | Type | Description |
+| --- | --- | --- |
+| `data` | `Ref<PBRTextures>` | Reactive reference containing all loaded textures |
+| `isLoading` | `Ref<boolean>` | Whether any texture is currently loading |
+| `error` | `Ref<Error \| null>` | Any error that occurred during loading |
+| `promise` | `Promise<PBRTextureResult>` | Promise that resolves when all textures are loaded |
+
+The `data` object contains the following properties:
+
+```ts
+{
+  map: Texture | null
+  normalMap: Texture | null
+  roughnessMap: Texture | null
+  metalnessMap: Texture | null
+  aoMap: Texture | null
+  displacementMap: Texture | null
+  emissiveMap: Texture | null
+}
+```
+
+## Notes
+
+- Built on top of the `useTexture` composable, providing the same loading behavior
+- All textures are loaded concurrently for better performance
+- Missing or undefined texture paths are ignored
+- Uses `shallowRef` for better performance with Three.js objects
+- Compatible with Vue's Suspense feature for loading states
+- When using without await, textures will start loading immediately but might not be fully loaded
+- Always check `isLoading` when using without await to ensure textures are ready

+ 35 - 0
playground/vue/src/pages/composables/usePBRTexture/ObjectAsyncPBRTexture.vue

@@ -0,0 +1,35 @@
+<script setup lang="ts">
+/* eslint-disable no-console */
+import { Html } from '@tresjs/cientos'
+import { usePBRTexture } from '@tresjs/core'
+
+const { data: textures, isLoading } = await usePBRTexture({
+  map: 'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Color.jpg',
+  normalMap: 'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_NormalDX.jpg',
+  roughnessMap: 'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Roughness.jpg',
+  aoMap: 'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_AmbientOcclusion.jpg',
+  displacementMap: 'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Displacement.jpg',
+})
+
+watch(textures, (newVal) => {
+  console.log('PBR texture async', newVal)
+}, { immediate: true })
+
+watch(isLoading, (newVal) => {
+  console.log('PBR texture async loading', newVal)
+}, { immediate: true })
+
+// eslint-enable no-console
+</script>
+
+<template>
+  <TresMesh :position="[3, 1, 0]">
+    <Html transform position-y="1.5">
+      <span class="text-xs bg-white p-2 rounded-md">
+        PBR (async) {{ isLoading ? 'Loading...' : 'Loaded' }}
+      </span>
+    </Html>
+    <TresSphereGeometry :args="[1, 32, 32]" />
+    <TresMeshPhysicalMaterial v-bind="textures" />
+  </TresMesh>
+</template>

+ 35 - 0
playground/vue/src/pages/composables/usePBRTexture/ObjectSyncPBRTexture.vue

@@ -0,0 +1,35 @@
+<script setup lang="ts">
+/* eslint-disable no-console */
+import { Html } from '@tresjs/cientos'
+import { usePBRTexture } from '@tresjs/core'
+
+const { data: textures, isLoading } = usePBRTexture({
+  map: 'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Color.jpg',
+  normalMap: 'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_NormalDX.jpg',
+  roughnessMap: 'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Roughness.jpg',
+  aoMap: 'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_AmbientOcclusion.jpg',
+  displacementMap: 'https://raw.githubusercontent.com/Tresjs/assets/main/textures/black-rock/Rock035_2K_Displacement.jpg',
+})
+
+watch(textures, (newVal) => {
+  console.log('PBR texture sync', newVal)
+}, { immediate: true })
+
+watch(isLoading, (newVal) => {
+  console.log('PBR texture sync loading', newVal)
+}, { immediate: true })
+
+// eslint-enable no-console
+</script>
+
+<template>
+  <TresMesh v-if="!isLoading" position-y="1">
+    <Html transform position-y="1.5">
+      <span class="text-xs bg-white p-2 rounded-md">
+        PBR (sync) {{ isLoading ? 'Loading...' : 'Loaded' }}
+      </span>
+    </Html>
+    <TresSphereGeometry :args="[1, 32, 32]" />
+    <TresMeshStandardMaterial v-bind="textures" />
+  </TresMesh>
+</template>

+ 30 - 0
playground/vue/src/pages/composables/usePBRTexture/index.vue

@@ -0,0 +1,30 @@
+<script setup lang="ts">
+import { TresCanvas } from '@tresjs/core'
+import { BasicShadowMap, NoToneMapping, SRGBColorSpace } from 'three'
+
+import { OrbitControls } from '@tresjs/cientos'
+import ObjectSyncPBRTexture from './ObjectSyncPBRTexture.vue'
+import ObjectAsyncPBRTexture from './ObjectAsyncPBRTexture.vue'
+
+const gl = {
+  clearColor: '#82DBC5',
+  shadows: true,
+  alpha: false,
+  shadowMapType: BasicShadowMap,
+  outputColorSpace: SRGBColorSpace,
+  toneMapping: NoToneMapping,
+}
+</script>
+
+<template>
+  <TresCanvas v-bind="gl">
+    <TresPerspectiveCamera :position="[8, 8, 8]" />
+    <OrbitControls />
+    <TresGridHelper :args="[100, 100]" />
+    <TresAmbientLight :intensity="1" />
+    <ObjectSyncPBRTexture />
+    <Suspense>
+      <ObjectAsyncPBRTexture />
+    </Suspense>
+  </TresCanvas>
+</template>

+ 5 - 0
playground/vue/src/router/routes/composables.ts

@@ -4,4 +4,9 @@ export const composablesRoutes = [
     path: '/composables/use-texture',
     component: () => import('../../pages/composables/useTexture/index.vue'),
   },
+  {
+    name: 'usePBRTexture',
+    path: '/composables/use-pbr-texture',
+    component: () => import('../../pages/composables/usePBRTexture/index.vue'),
+  },
 ]

+ 1 - 0
src/composables/index.ts

@@ -5,6 +5,7 @@ export * from './useCamera/'
 export * from './useLoader'
 export * from './useLogger'
 export * from './useLoop'
+export * from './usePBRTexture'
 export * from './useRaycaster'
 export * from './useRenderer/'
 export * from './useRenderLoop'

+ 147 - 0
src/composables/usePBRTexture/index.ts

@@ -0,0 +1,147 @@
+import type { Ref, ShallowRef } from 'vue'
+import { shallowRef } from 'vue'
+import type { Texture } from 'three'
+import { useTexture } from '../useTexture'
+
+export interface PBRTextureOptions {
+  /**
+   * Base color or albedo texture path
+   */
+  map?: string
+  /**
+   * Normal map texture path
+   */
+  normalMap?: string
+  /**
+   * Roughness map texture path
+   */
+  roughnessMap?: string
+  /**
+   * Metalness map texture path
+   */
+  metalnessMap?: string
+  /**
+   * Ambient occlusion map texture path
+   */
+  aoMap?: string
+  /**
+   * Height/Displacement map texture path
+   */
+  displacementMap?: string
+  /**
+   * Emissive map texture path
+   */
+  emissiveMap?: string
+}
+
+export interface PBRTextures {
+  map: Texture | null
+  normalMap: Texture | null
+  roughnessMap: Texture | null
+  metalnessMap: Texture | null
+  aoMap: Texture | null
+  displacementMap: Texture | null
+  emissiveMap: Texture | null
+}
+
+export interface PBRTextureResult {
+  /**
+   * The loaded PBR textures
+   */
+  data: Ref<PBRTextures>
+  /**
+   * Whether any texture is currently loading
+   */
+  isLoading: Ref<boolean>
+  /**
+   * Any error that occurred during loading
+   */
+  error: Ref<Error | null>
+  /**
+   * Promise that resolves when all textures are loaded
+   */
+  promise: Promise<PBRTextureResult>
+}
+
+/**
+ * Vue composable for loading PBR texture sets with THREE.js
+ * Provides a simplified way to load and manage physically based rendering textures
+ *
+ * @example
+ * ```ts
+ * // Basic usage
+ * const { data: textures } = await usePBRTexture({
+ *   map: 'textures/wood/albedo.jpg',
+ *   normalMap: 'textures/wood/normal.jpg',
+ *   roughnessMap: 'textures/wood/roughness.jpg',
+ * })
+ *
+ * // In template
+ * <TresMeshStandardMaterial v-bind="textures.value" />
+ * ```
+ *
+ * @param options - Object containing paths to PBR textures
+ */
+export function usePBRTexture(options: PBRTextureOptions): PBRTextureResult & Promise<PBRTextureResult> {
+  const data: ShallowRef<PBRTextures> = shallowRef({
+    map: null,
+    normalMap: null,
+    roughnessMap: null,
+    metalnessMap: null,
+    aoMap: null,
+    displacementMap: null,
+    emissiveMap: null,
+  })
+  const isLoading = shallowRef(true)
+  const error = shallowRef<Error | null>(null)
+
+  // Filter out undefined paths and create a map of texture types
+  const textureEntries = Object.entries(options).filter(([_, path]) => path !== undefined)
+
+  // Load all textures concurrently using useTexture
+  const loadPromises = textureEntries.map(async ([type, path]) => {
+    try {
+      const { data: texture } = useTexture(path)
+      // Update the textures ref when each texture loads
+      data.value[type as keyof PBRTextures] = texture.value
+    }
+    catch (err) {
+      error.value = err as Error
+      console.error(`Failed to load ${type} texture:`, err)
+    }
+  })
+
+  // Create a promise that resolves when all textures are loaded
+  const loadAllTextures = async () => {
+    try {
+      await Promise.all(loadPromises)
+      isLoading.value = false
+    }
+    catch (err) {
+      error.value = err as Error
+      isLoading.value = false
+      throw err
+    }
+
+    const result: PBRTextureResult = {
+      data,
+      isLoading,
+      error,
+      promise: Promise.resolve({ data, isLoading, error, promise: Promise.resolve({} as PBRTextureResult) }),
+    }
+
+    return result
+  }
+
+  const promise = loadAllTextures()
+
+  // Make the return value awaitable
+  const returnValue = {
+    data,
+    isLoading,
+    error,
+    promise,
+  } as PBRTextureResult & Promise<PBRTextureResult>
+
+  return returnValue
+}

+ 170 - 0
src/composables/usePBRTexture/usePBRTexture.test.ts

@@ -0,0 +1,170 @@
+import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
+import { Texture } from 'three'
+import { usePBRTexture } from '.'
+import { nextTick, shallowRef } from 'vue'
+
+const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0))
+
+// Mock useTexture module
+vi.mock('../useTexture', () => {
+  const mockTexture = new Texture()
+  return {
+    useTexture: vi.fn().mockImplementation((_) => {
+      const data = shallowRef(mockTexture)
+      const isLoading = shallowRef(true)
+      const error = shallowRef(null)
+
+      const result = {
+        data,
+        isLoading,
+        error,
+        // Delay the promise resolution to simulate actual loading
+        promise: new Promise((resolve) => {
+          setTimeout(async () => {
+            await nextTick()
+            isLoading.value = false
+            resolve({ data, isLoading, error })
+          }, 0)
+        }),
+      }
+
+      // Make result thenable
+      return Object.assign(result, {
+        then(onfulfilled, onrejected) {
+          return result.promise.then(onfulfilled, onrejected)
+        },
+      })
+    }),
+  }
+})
+
+describe('usePBRTexture', () => {
+  const mockPBROptions = {
+    map: 'textures/wood/albedo.jpg',
+    normalMap: 'textures/wood/normal.jpg',
+    roughnessMap: 'textures/wood/roughness.jpg',
+    metalnessMap: 'textures/wood/metalness.jpg',
+  }
+
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  afterEach(() => {
+    vi.clearAllMocks()
+  })
+
+  describe('basic functionality', () => {
+    it('should return textures synchronously', async () => {
+      const { data, isLoading, error } = usePBRTexture(mockPBROptions)
+
+      expect(data.value).toBeDefined()
+      expect(data.value.map).toBeDefined()
+      expect(data.value.normalMap).toBeDefined()
+      expect(data.value.roughnessMap).toBeDefined()
+      expect(data.value.metalnessMap).toBeDefined()
+      expect(isLoading.value).toBe(true)
+      expect(error.value).toBe(null)
+
+      await flushPromises()
+      expect(isLoading.value).toBe(false)
+    })
+
+    it('should update loading state when all textures load', async () => {
+      const { data, isLoading, error } = usePBRTexture(mockPBROptions)
+
+      expect(data.value).toBeDefined()
+      expect(isLoading.value).toBe(true)
+
+      await flushPromises()
+      expect(isLoading.value).toBe(false)
+      expect(error.value).toBe(null)
+    })
+
+    it('should work with async/await', async () => {
+      const result = await usePBRTexture(mockPBROptions)
+
+      // After awaiting, we should still be loading because we need to wait for the next tick
+      expect(result.isLoading.value).toBe(true)
+      expect(result.error.value).toBe(null)
+      expect(result.data.value).toBeDefined()
+
+      await flushPromises()
+      expect(result.isLoading.value).toBe(false)
+    })
+  })
+
+  describe('error handling', () => {
+    it('should handle loading errors', async () => {
+      // Mock useTexture to throw error for this test
+      const useTextureMock = (await import('../useTexture')).useTexture as unknown as Mock
+      useTextureMock.mockImplementationOnce(() => {
+        throw new Error('Failed to load texture')
+      })
+
+      const { isLoading, error, promise } = usePBRTexture(mockPBROptions)
+
+      expect(isLoading.value).toBe(true)
+
+      // Catch the promise rejection to prevent unhandled rejection
+      await promise.catch(() => {
+        // Expected to reject
+      })
+
+      await flushPromises()
+      expect(error.value).toBeTruthy()
+      expect(error.value?.message).toContain('Failed to load texture')
+      expect(isLoading.value).toBe(false)
+    })
+  })
+
+  describe('partial textures', () => {
+    it('should handle partial texture sets', async () => {
+      const partialOptions = {
+        map: 'textures/wood/albedo.jpg',
+        normalMap: 'textures/wood/normal.jpg',
+      }
+
+      const { data, isLoading } = usePBRTexture(partialOptions)
+
+      expect(isLoading.value).toBe(true)
+      expect(data.value.map).toBeDefined()
+      expect(data.value.normalMap).toBeDefined()
+      expect(data.value.roughnessMap).toBe(null)
+      expect(data.value.metalnessMap).toBe(null)
+
+      await flushPromises()
+      expect(isLoading.value).toBe(false)
+    })
+
+    it('should ignore undefined texture paths', async () => {
+      const partialOptions = {
+        map: 'textures/wood/albedo.jpg',
+        normalMap: undefined,
+      }
+
+      const { data, isLoading } = usePBRTexture(partialOptions)
+
+      expect(isLoading.value).toBe(true)
+      expect(data.value.map).toBeDefined()
+      expect(data.value.normalMap).toBe(null)
+
+      await flushPromises()
+      expect(isLoading.value).toBe(false)
+    })
+  })
+
+  describe('promise behavior', () => {
+    it('should be thenable', async () => {
+      const result = await usePBRTexture(mockPBROptions)
+
+      // After awaiting, we should still be loading because we need to wait for the next tick
+      expect(result.isLoading.value).toBe(true)
+      expect(result.error.value).toBe(null)
+      expect(result.data.value).toBeDefined()
+
+      await flushPromises()
+      expect(result.isLoading.value).toBe(false)
+    })
+  })
+})