Working with the Model
To get started, you’ll need a 3D model in GLB/GLTF format. I recommend using a base white model, as this provides the most flexibility for applying colors and textures dynamically.
If you want to apply separate colors or textures to specific parts of your model, you can select those model faces and assign them different materials. For example, my fish model has separate materials for:
- Eyes
- Fins
- Upper body
- Lower body
This separation allows me to control the color and texture of each piece independently.
Loading a Model with Three.js
const { scene, nodes } = useGLTF(`/models/fish/${fish.physicalAttributes.model}.glb`);
Texture Mapping
Texture mapping is the technique we use to apply patterns or details to our model. Here’s how it works:
- The texture map overlays or wraps around your model with the pattern you want to use
- We apply a shader to the model that allows us to apply color to the white areas of our texture map while hiding or applying a separate color to the black areas

Example texture maps: Black and white patterns to wrap our model. White areas will receive your pattern color while black areas will show the base color.
Creating the Patterned Material
Here’s how to create the patterned material for the upper body of my fish example:
const fishUpperBodyMaterial = await createTextureMaterial(fish);
We’ll need to load our texture image files and create a shader material:
export async function createBodyMaterial(
baseColorHex: string,
patternColorHex: string
) {
const textureImage = await loadTexture(`/textures/patterns/polkadot-pattern.png`);
const material = new THREE.ShaderMaterial({
uniforms: {
primaryColor: { value: new THREE.Color(baseColorHex) },
patternColor: { value: new THREE.Color(patternColorHex) },
patternTexture: { value: textureImage },
},
vertexShader: `
#include <common>
#include <skinning_pars_vertex>
varying vec2 vUv;
varying vec3 vPosition;
void main() {
vUv = uv;
#include <skinbase_vertex>
#include <begin_vertex>
#include <skinning_vertex>
#include <project_vertex>
vPosition = transformed;
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
}
`,
fragmentShader: `
uniform vec3 primaryColor;
uniform vec3 patternColor;
uniform sampler2D patternTexture;
varying vec2 vUv;
varying vec3 vPosition;
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);
}
float noise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
float a = random(i);
float b = random(i + vec2(1.0, 0.0));
float c = random(i + vec2(0.0, 1.0));
float d = random(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(a, b, u.x) +
(c - a)* u.y * (1.0 - u.x) +
(d - b) * u.x * u.y;
}
// Function to convert RGB to HSL
vec3 rgb2hsl(vec3 color) {
float maxColor = max(max(color.r, color.g), color.b);
float minColor = min(min(color.r, color.g), color.b);
float delta = maxColor - minColor;
float h = 0.0;
float s = 0.0;
float l = (maxColor + minColor) / 2.0;
if (delta > 0.0) {
s = l < 0.5 ? delta / (maxColor + minColor) : delta / (2.0 - maxColor - minColor);
if (color.r == maxColor) {
h = (color.g - color.b) / delta + (color.g < color.b ? 6.0 : 0.0);
} else if (color.g == maxColor) {
h = (color.b - color.r) / delta + 2.0;
} else {
h = (color.r - color.g) / delta + 4.0;
}
h /= 6.0;
}
return vec3(h, s, l);
}
// Function to convert HSL to RGB
float hue2rgb(float p, float q, float t) {
if (t < 0.0) t += 1.0;
if (t > 1.0) t -= 1.0;
if (t < 1.0/6.0) return p + (q - p) * 6.0 * t;
if (t < 1.0/2.0) return q;
if (t < 2.0/3.0) return p + (q - p) * (2.0/3.0 - t) * 6.0;
return p;
}
vec3 hsl2rgb(vec3 hsl) {
float h = hsl.x;
float s = hsl.y;
float l = hsl.z;
if (s == 0.0) {
return vec3(l, l, l);
}
float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s;
float p = 2.0 * l - q;
return vec3(
hue2rgb(p, q, h + 1.0/3.0),
hue2rgb(p, q, h),
hue2rgb(p, q, h - 1.0/3.0)
);
}
void main() {
// Sample the pattern texture
vec4 pattern = texture2D(patternTexture, vUv); // Fixed: was modifiedUV
// Use the pattern's red channel as the blend factor
float patternValue = pattern.r; // Fixed: was undefined
// Mix the base color and pattern color based on the pattern value
vec3 finalColor = mix(primaryColor, patternColor, patternValue);
gl_FragColor = vec4(finalColor, 1.0);
}
`,
side: THREE.DoubleSide,
});
return material;
}
Working with Shaders
We utilize a fragment shader to apply separate colors to the contrasting areas in our texture map. Since our texture map is black and white, we can use a mix function to transform these color values into any other colors we desire.
The process works like this:
- Apply a base color to the model material itself
- Apply another color to the texture that wraps around it
- For example, if you want a blue fish with orange polka dots, you would apply blue as the base color and orange to the white areas of your texture map
Because we assigned different materials to our model during creation, we can apply shaders to each material belonging to our mesh (model) independently with Three.js.
For the remaining parts of my fish that use solid colors, I use a basic shader material created through a separate function.
Applying the Materials
Once we’ve created all the materials we need, we can traverse through our model’s scene and replace the base materials with our custom shader materials:
scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
const meshName = child.name;
if (blackOut) {
child.material = eyeMaterial;
}
else if (meshName === 'Fish_Base') {
child.material = baseMaterial;
} else if (meshName === 'Fish_Accent') {
child.material = accentMaterial;
} else if (meshName === 'Fish_Black') {
child.material = eyeMaterial;
}
else if (meshName === 'Fish_Upper') {
child.material = bodyMaterial;
}
}
});
This approach allows us to generate countless variations of our model by simply changing the color values and texture patterns, creating a virtually infinite number of unique appearances from a single base model.