diff --git a/src/lib/effects/billboard/gpu/gpu-textures-state.ts b/src/lib/effects/billboard/gpu/gpu-textures-state.ts index b332b73d..f88841f0 100644 --- a/src/lib/effects/billboard/gpu/gpu-textures-state.ts +++ b/src/lib/effects/billboard/gpu/gpu-textures-state.ts @@ -1,3 +1,4 @@ +import { createFullscreenQuad } from '../../../helpers/fullscreen-quad'; import * as THREE from '../../../libs/three-usage'; type UniformType = 'sampler2D' | 'float' | 'vec2' | 'vec3' | 'vec4'; @@ -38,11 +39,7 @@ class GpuTexturesState { this.textureNames = params.textureNames; - const fullscreenQuadGeometry = new THREE.BufferGeometry(); - fullscreenQuadGeometry.setAttribute('aPosition', new THREE.Float32BufferAttribute([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1], 2)); - fullscreenQuadGeometry.setDrawRange(0, 6); - this.fullscreenQuad = new THREE.Mesh(fullscreenQuadGeometry); - this.fullscreenQuad.frustumCulled = false; + this.fullscreenQuad = createFullscreenQuad('aPosition'); const vertexShader = ` in vec2 aPosition; diff --git a/src/lib/helpers/customizable-texture.ts b/src/lib/helpers/customizable-texture.ts new file mode 100644 index 00000000..68daf570 --- /dev/null +++ b/src/lib/helpers/customizable-texture.ts @@ -0,0 +1,168 @@ +import * as THREE from '../libs/three-usage'; + +import { createFullscreenQuad } from './fullscreen-quad'; +import { logger } from './logger'; + +type Parameters = { + readonly baseTexture: THREE.Texture; + readonly additionalTextures: ReadonlyMap; +}; + +type TextureLayer = { + readonly texture: THREE.Texture; + readonly color: THREE.Color; +}; + +class CustomizableTexture { + public readonly texture: THREE.Texture; + + private readonly baseTexture: THREE.Texture; + private readonly layers: ReadonlyMap; + + private readonly renderTarget: THREE.WebGLRenderTarget; + private readonly fakeCamera = new THREE.PerspectiveCamera(); + private readonly fullscreenQuad = createFullscreenQuad('aPosition'); + private readonly applyLayer: { + readonly shader: THREE.RawShaderMaterial; + readonly uniforms: { + readonly layer: THREE.IUniform; + readonly color: THREE.IUniform; + readonly flipY: THREE.IUniform; + }; + }; + + public constructor(params: Parameters) { + this.baseTexture = params.baseTexture; + + this.renderTarget = new THREE.WebGLRenderTarget(this.baseTexture.image.width, this.baseTexture.image.height, { + wrapS: this.baseTexture.wrapS, + wrapT: this.baseTexture.wrapT, + magFilter: this.baseTexture.magFilter, + // minFilter: this.baseTexture.minFilter, + depthBuffer: false, + }); + const texture = this.renderTarget.textures[0]; + if (!texture) { + throw new Error(`Cannot get texture from rendertarget`); + } + this.texture = texture; + + const layers = new Map(); + for (const [name, texture] of params.additionalTextures.entries()) { + if (texture.image.width !== this.renderTarget.width || texture.image.height !== this.renderTarget.height) { + logger.warn( + `Invalid texture size: expected "${this.renderTarget.width}x${this.renderTarget.height}" but received "${texture.image.width}x${texture.image.height}".` + ); + } + layers.set(name, { texture, color: new THREE.Color(0xffffff) }); + } + this.layers = layers; + + const uniforms = { + layer: { value: null }, + color: { value: new THREE.Color(0xffffff) }, + flipY: { value: 0 }, + }; + + const shader = new THREE.RawShaderMaterial({ + glslVersion: '300 es', + depthTest: false, + blending: THREE.CustomBlending, + blendSrc: THREE.SrcAlphaFactor, + blendDst: THREE.OneMinusSrcAlphaFactor, + blendSrcAlpha: THREE.ZeroFactor, + blendDstAlpha: THREE.OneFactor, + uniforms: { + uLayerTexture: uniforms.layer, + uLayerColor: uniforms.color, + uFlipY: uniforms.flipY, + }, + vertexShader: ` +uniform float uFlipY; + +in vec2 aPosition; + +out vec2 vUv; + +void main() { + gl_Position = vec4(2.0 * aPosition - 1.0, 0, 1); + vUv = vec2( + aPosition.x, + mix(aPosition.y, 1.0 - aPosition.y, uFlipY) + ); +}`, + fragmentShader: ` +precision mediump float; + +uniform sampler2D uLayerTexture; +uniform vec3 uLayerColor; + +in vec2 vUv; + +layout(location = 0) out vec4 fragColor; + +void main() { + vec4 sampled = texture(uLayerTexture, vUv); + if (sampled.a < 0.5) discard; + sampled.rgb *= uLayerColor; + fragColor = sampled; +} +`, + }); + this.fullscreenQuad.material = shader; + + this.applyLayer = { shader, uniforms }; + } + + public setLayerColor(layerName: string, color: THREE.Color): void { + const layer = this.layers.get(layerName); + if (!layer) { + const layerNames = Array.from(this.layers.keys()); + throw new Error(`Unknown layer name "${layerName}". Layer names are: ${layerNames.join('; ')}.`); + } + + if (layer.color.equals(color)) { + return; // nothing to do + } + + layer.color.set(color); + } + + public update(renderer: THREE.WebGLRenderer): void { + const previousState = { + renderTarget: renderer.getRenderTarget(), + clearColor: renderer.getClearColor(new THREE.Color()), + clearAlpha: renderer.getClearAlpha(), + autoClear: renderer.autoClear, + autoClearColor: renderer.autoClearColor, + }; + + renderer.setRenderTarget(this.renderTarget); + renderer.setClearColor(0x000000, 0); + renderer.autoClear = false; + renderer.autoClearColor = false; + renderer.clear(true); + + this.applyLayer.uniforms.layer.value = this.baseTexture; + this.applyLayer.uniforms.color.value = new THREE.Color(0xffffff); + this.applyLayer.uniforms.flipY.value = Number(this.baseTexture.flipY); + this.applyLayer.shader.uniformsNeedUpdate = true; + renderer.render(this.fullscreenQuad, this.fakeCamera); + + for (const layer of this.layers.values()) { + this.applyLayer.uniforms.layer.value = layer.texture; + this.applyLayer.uniforms.color.value = layer.color; + this.applyLayer.uniforms.flipY.value = Number(layer.texture.flipY); + this.applyLayer.shader.uniformsNeedUpdate = true; + renderer.render(this.fullscreenQuad, this.fakeCamera); + } + + renderer.setRenderTarget(previousState.renderTarget); + renderer.setClearColor(previousState.clearColor); + renderer.setClearAlpha(previousState.clearAlpha); + renderer.autoClear = previousState.autoClear; + renderer.autoClearColor = previousState.autoClearColor; + } +} + +export { CustomizableTexture }; diff --git a/src/lib/helpers/fullscreen-quad.ts b/src/lib/helpers/fullscreen-quad.ts new file mode 100644 index 00000000..d1545485 --- /dev/null +++ b/src/lib/helpers/fullscreen-quad.ts @@ -0,0 +1,12 @@ +import * as THREE from '../libs/three-usage'; + +function createFullscreenQuad(attributeName: string): THREE.Mesh { + const fullscreenQuadGeometry = new THREE.BufferGeometry(); + fullscreenQuadGeometry.setAttribute(attributeName, new THREE.Float32BufferAttribute([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1], 2)); + fullscreenQuadGeometry.setDrawRange(0, 6); + const fullscreenQuad = new THREE.Mesh(fullscreenQuadGeometry); + fullscreenQuad.frustumCulled = false; + return fullscreenQuad; +} + +export { createFullscreenQuad }; diff --git a/src/lib/index.ts b/src/lib/index.ts index c7eace2f..e52aee79 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -35,3 +35,5 @@ export { type Spritesheet } from './effects/spritesheet'; export { Rain } from './effects/weather/rain'; export { Snow } from './effects/weather/snow'; export { GpuInstancedBillboard } from './effects/weather/weather-particles-base'; + +export { CustomizableTexture } from './helpers/customizable-texture'; diff --git a/src/lib/libs/three-usage.ts b/src/lib/libs/three-usage.ts index d803793d..f7cab21e 100644 --- a/src/lib/libs/three-usage.ts +++ b/src/lib/libs/three-usage.ts @@ -4,6 +4,7 @@ export { Box3, BufferGeometry, Color, + CustomBlending, DataTexture, Float32BufferAttribute, Group, @@ -18,6 +19,8 @@ export { MeshPhongMaterial, NoBlending, NormalBlending, + OneFactor, + OneMinusSrcAlphaFactor, PerspectiveCamera, PlaneGeometry, RawShaderMaterial, @@ -26,6 +29,7 @@ export { RGBAFormat, ShaderMaterial, Sphere, + SrcAlphaFactor, Texture, TextureLoader, Uint32BufferAttribute, @@ -34,6 +38,7 @@ export { Vector3, Vector4, WebGLRenderTarget, + ZeroFactor, type Frustum, type IUniform, type Material, diff --git a/src/lib/terrain/voxelmap/i-voxelmap.ts b/src/lib/terrain/voxelmap/i-voxelmap.ts index 836a2f08..022f6e13 100644 --- a/src/lib/terrain/voxelmap/i-voxelmap.ts +++ b/src/lib/terrain/voxelmap/i-voxelmap.ts @@ -26,7 +26,7 @@ interface ILocalMapData { * Each element in the array represent a coordinate in the map and stores the data of the voxel at these coordinates. * Each element should be encoded as follows: * - bit 0: 0 if the voxel is empty, 1 otherwise - * - bit 1: 1 if the voxel should be displayed as cheesserboard, 0 otherwise + * - bit 1: 1 if the voxel should be displayed as checkerboard, 0 otherwise * - bits 2-13: ID of the material * Use the helper "voxelmapDataPacking" to do this encoding and be future-proof. * diff --git a/src/test/libs/three-usage-test.ts b/src/test/libs/three-usage-test.ts index d4d0668d..0a146ba4 100644 --- a/src/test/libs/three-usage-test.ts +++ b/src/test/libs/three-usage-test.ts @@ -18,5 +18,7 @@ export { } from 'three'; export { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; export { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'; +export { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +export { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; export * from '../../lib/libs/three-usage'; export { Stats }; diff --git a/src/test/main.ts b/src/test/main.ts index 9a6712ae..7c15571b 100644 --- a/src/test/main.ts +++ b/src/test/main.ts @@ -4,27 +4,40 @@ import { VoxelMap } from './map/voxel-map'; import { type TestBase } from './test-base'; import { TestTerrain } from './test-terrain'; import { TestTerrainAutonomous } from './test-terrain-autonomous'; +import { TestTextureCustomization } from './test-texture-customization'; import { TestWeather } from './test-weather'; setVerbosity(ELogLevel.WARN); -const mapScaleXZ = 800; -const mapScaleY = 200; -const mapSeed = 'fixed_seed'; -const includeTreesInLod = false; +function createVoxelMap(): VoxelMap { + const mapScaleXZ = 800; + const mapScaleY = 200; + const mapSeed = 'fixed_seed'; + const includeTreesInLod = false; -const voxelMap = new VoxelMap(mapScaleXZ, mapScaleY, mapSeed, includeTreesInLod); + return new VoxelMap(mapScaleXZ, mapScaleY, mapSeed, includeTreesInLod); +} + +enum ETest { + TERRAIN, + TERRAIN_OLD, + WEATHER, + TEXTURE_CUSTOMIZATION, +} + +const test = ETest.TEXTURE_CUSTOMIZATION as ETest; let testScene: TestBase; -const testTerrain = false; -if (testTerrain) { - const testNewTerrain = true; - if (testNewTerrain) { - testScene = new TestTerrain(voxelMap); - } else { - testScene = new TestTerrainAutonomous(voxelMap); - } -} else { +if (test === ETest.TERRAIN) { + testScene = new TestTerrain(createVoxelMap()); +} else if (test === ETest.TERRAIN_OLD) { + testScene = new TestTerrainAutonomous(createVoxelMap()); +} else if (test === ETest.WEATHER) { testScene = new TestWeather(); +} else if (test === ETest.TEXTURE_CUSTOMIZATION) { + testScene = new TestTextureCustomization(); +} else { + throw new Error(`Unknown test "${test}".`); } + testScene.start(); diff --git a/src/test/test-texture-customization.ts b/src/test/test-texture-customization.ts new file mode 100644 index 00000000..6ca742ac --- /dev/null +++ b/src/test/test-texture-customization.ts @@ -0,0 +1,87 @@ +import { GUI } from 'lil-gui'; +import * as THREE from 'three-usage-test'; + +import { CustomizableTexture } from '../lib'; + +import { TestBase } from './test-base'; + +class TestTextureCustomization extends TestBase { + private readonly gui: GUI; + + private readonly parameters = { + color1: 0xff0000, + color2: 0x00ff00, + }; + + private customizableTexture: CustomizableTexture | null = null; + + public constructor() { + super(); + + this.camera.position.set(2, 2, 4); + this.cameraControl.target.set(0, this.camera.position.y - 1.5, 0); + + const gridHelper = new THREE.GridHelper(1000, 100); + gridHelper.position.setY(-0.01); + this.scene.add(gridHelper); + + const ambientLight = new THREE.AmbientLight(0xffffff); + this.scene.add(ambientLight); + + const enforceColors = () => { + this.enforceColors(); + }; + this.gui = new GUI(); + this.gui.addColor(this.parameters, 'color1').onChange(enforceColors); + this.gui.addColor(this.parameters, 'color2').onChange(enforceColors); + enforceColors(); + + const gltfLoader = new THREE.GLTFLoader(); + const dracoLoader = new THREE.DRACOLoader(); + dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/'); + dracoLoader.setDecoderConfig({ type: 'js' }); + gltfLoader.setDRACOLoader(dracoLoader); + + Promise.all([ + gltfLoader.loadAsync('/resources/character/iop_male.glb'), + new THREE.TextureLoader().loadAsync('/resources/character/color_01.png'), + new THREE.TextureLoader().loadAsync('/resources/character/color_02.png'), + ]).then(([gltf, color1Texture, color2Texture]) => { + this.scene.add(gltf.scene); + + gltf.scene.traverse(child => { + if ((child as any).isMesh) { + const childMesh = child as THREE.Mesh; + const childMaterial = childMesh.material as THREE.MeshPhongMaterial; + + const childTexture = childMaterial.map; + if (!childTexture) { + throw new Error('No base texture'); + } + + this.customizableTexture = new CustomizableTexture({ + baseTexture: childTexture, + additionalTextures: new Map([ + ['color1', color1Texture], + ['color2', color2Texture], + ]), + }); + this.enforceColors(); + childMaterial.map = this.customizableTexture.texture; + } + }); + }); + } + + protected override update(): void {} + + private enforceColors(): void { + if (this.customizableTexture) { + this.customizableTexture.setLayerColor('color1', new THREE.Color(this.parameters.color1)); + this.customizableTexture.setLayerColor('color2', new THREE.Color(this.parameters.color2)); + this.customizableTexture.update(this.renderer); + } + } +} + +export { TestTextureCustomization }; diff --git a/test/resources/character/char01male.png b/test/resources/character/char01male.png new file mode 100644 index 00000000..3b3bf885 Binary files /dev/null and b/test/resources/character/char01male.png differ diff --git a/test/resources/character/color_01.png b/test/resources/character/color_01.png new file mode 100644 index 00000000..4f8e6bf1 Binary files /dev/null and b/test/resources/character/color_01.png differ diff --git a/test/resources/character/color_02.png b/test/resources/character/color_02.png new file mode 100644 index 00000000..ae0d6340 Binary files /dev/null and b/test/resources/character/color_02.png differ diff --git a/test/resources/character/iop_male.glb b/test/resources/character/iop_male.glb new file mode 100644 index 00000000..3d64d407 Binary files /dev/null and b/test/resources/character/iop_male.glb differ