Applying texture continuously on the side of an object with shader

In threejs, i’m working on a showerbase project using shader. One of my material got visible mineral veins on it and I noticed when it wrap around the border, the veins are not aligned.

I made a simple cube using the material to demonstrate the problem:

The final product can change in size and I can also cut geometry in it depending on user input but a simple cube illustrate well the problem. What technique should I use to achieve the desire result of having the texture align.

Here is the current code of the shader:

mat.onBeforeCompile = function( shader ) {
        // --- Custom Uniforms and Varyings for Tri-planar ---
        if (mat.map) { // Only apply tri-planar if the material has a texture map
            shader.uniforms.triplanarTexture = { value: mat.map }; // The texture itself
            shader.uniforms.triplanarScale = mat.userData.triplanarScale;
            
            // *** NEW: Add Uniform for Normal Map ***
            if (mat.normalMap) {
                shader.uniforms.triplanarNormalMap = { value: mat.normalMap }; 
            }
            
            // Declare our own custom varyings to avoid conflicts
            shader.vertexShader = `
                varying vec3 vWorldPosition;
                varying vec3 vTriplanarNormal; // Use a distinct name for world-space normal
                ${shader.vertexShader}
            `;
            shader.vertexShader = shader.vertexShader.replace(
                '#include <begin_vertex>',
                `
                #include <begin_vertex>
                vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
                vTriplanarNormal = normalize(mat3(modelMatrix) * normal); // Transform normal to world space
                `
            );
  
            // --- Fragment Shader Modifications for Tri-planar ---
            shader.fragmentShader = `
                uniform float triplanarScale;
                uniform sampler2D triplanarTexture;
                varying vec3 vWorldPosition;
                varying vec3 vTriplanarNormal; // Use the distinct name in fragment shader too
                ${shader.fragmentShader}
            `;
  
            const triplanarSampleFunction = `
                vec4 sampleTriplanarTexture(sampler2D tex, vec3 p, vec3 n, float scale) {
                    vec3 blend_weights = abs(n);
                    // Add a small epsilon to avoid division by zero for perfectly axis-aligned normals
                    blend_weights = normalize(max(vec3(0.001), blend_weights));
                    blend_weights /= (blend_weights.x + blend_weights.y + blend_weights.z);
  
                    // Project on YZ (X-axis dominant)
                    vec4 colX = texture2D(tex, p.yz * scale);
                    // Project on XZ (Y-axis dominant)
                    vec4 colY = texture2D(tex, p.xz * scale);
                    // Project on XY (Z-axis dominant)
                    vec4 colZ = texture2D(tex, p.xy * scale);
  
                    // Blend based on normal weights
                    return colX * blend_weights.x + colY * blend_weights.y + colZ * blend_weights.z;
                }
            `;
  
            shader.fragmentShader = shader.fragmentShader.replace(
                'void main() {',
                `${triplanarSampleFunction}\nvoid main() {`
            );
  
            // Replace the standard map lookup with our tri-planar lookup
            shader.fragmentShader = shader.fragmentShader.replace(
                '#include <map_fragment>',
                `
                // Calculate texture color using tri-planar mapping
                // Use vTriplanarNormal here!
                vec4 texelColor = sampleTriplanarTexture(triplanarTexture, vWorldPosition, vTriplanarNormal, triplanarScale);
  
                // Use texelColor instead of standard map lookup
                diffuseColor *= texelColor; // Apply the triplanar texture to diffuseColor
                `
            );
        }
        
        shader.fragmentShader = shader.fragmentShader.replace(
            '#include <output_fragment>',
            `
            #ifdef OPAQUE
            gl_FragColor = vec4( outgoingLight, opacity );
            #else
            gl_FragColor = vec4( outgoingLight, diffuseColor.a ); // Use diffuseColor.a for transparency if applicable
            #endif
  
            // Apply backface color after the primary fragment output,
            // but before any final adjustments like alpha blending if not already handled.
            // This directly overrides gl_FragColor for backfaces.
            if (!gl_FrontFacing) {
                vec3 backfaceColor = vec3( 0.4, 0.4, 0.4 );
                gl_FragColor = vec4( backfaceColor, opacity ); // Ensure opacity is used for backface
            }
            `
        );
        mat.userData.shader = shader;
    };

This is not an answer to your question, but a TSL texture could solve continuity of texture patterns:

https://codepen.io/boytchev/full/YPqrpea

image

Here is a demo with a dynamic structure:

https://codepen.io/boytchev/full/NPNabOQ

image

3 Likes

This is also not an answer to OP’s question, but I want to be pedantic and say that a marble slab does not look like that - the streaks on a real slab would not continue in the same pattern and direction as if they were painted on the slab after it has been cut.

In a realistic rendering the transition of streaks around the corners would be seamless, but they would also go off in a different (perpendicular?) direction.

This reminds me of the sloppy way brick walls are often rendered, where the arrangement of the bricks makes no sense.

4 Likes

I have try the TSL on your codepen but it crash anytime I try to send an image, can TSL handle image?

The marble texture used in the demo is generated at runtime in a shader, it is not an image. It is a marble pattern spread ‘infinitly’ in 3D, and the faces of a mesh just cut through the space and show what’s texture happens to be there.

This is not TSL itself, it is procedural texture written in TSL (TSL = Three.js Shading Language). That’s why I mentioned it is not a solution for your case, but just an alternative approach.

1 Like

Thank you, that’s what I though but I was getting pretty desperate so I did try anything which could help me at that point.

I settled with a not perfect solution :

I started by making a function to align my texture at the bottom left which I call in the animation() function

export function applyTriplanarBottomLeftOffset(mesh, material, options = {}) {
    const autoScale    = options.autoScale ?? false;   // scale texture to fit mesh
    const uniformScale = options.scale     ?? material.userData.ratio;     // manual scale multiplier
    if (!material.userData.shader) {
        console.warn("Shader not yet compiled. Call this AFTER first render.");
        return;
    }
    const shader = material.userData.shader;
    const bbox = new THREE.Box3().setFromObject(mesh);
    const size = bbox.getSize(new THREE.Vector3());
    let min  = bbox.min;     // bottom-left-near corner (world-space!)
    min.x = min.x * uniformScale;
    min.y = min.y * uniformScale;
    min.z = min.z * uniformScale;
    
    shader.uniforms.triplanarScaleX = { value: uniformScale };
    shader.uniforms.triplanarScaleY = { value: uniformScale };
    shader.uniforms.triplanarScaleZ = { value: uniformScale };
    shader.uniforms.triplanarOffset.value.copy(min);

    shader.uniforms.triplanarScaleX.value = uniformScale
    shader.uniforms.triplanarScaleY.value = uniformScale
    shader.uniforms.triplanarScaleZ.value = uniformScale
}

Then in the shader I adjusted the front and the right element to match the top view

vec4 sampleTriplanarTexture(sampler2D tex, vec3 p, vec3 n, vec3 triplanarOffset, float triplanarScaleX, float triplanarScaleY, float triplanarScaleZ) {
                    vec3 blend_weights = abs(n);
                    blend_weights = normalize(max(vec3(0.001), blend_weights));
                    blend_weights /= (blend_weights.x + blend_weights.y + blend_weights.z);
                    
                    vec2 uvX = vec2( p.y * triplanarScaleY, p.z * triplanarScaleZ ) + triplanarOffset.yz;
                    vec2 uvY = vec2( p.x * triplanarScaleX, p.z * triplanarScaleZ ) + triplanarOffset.xz;
                    vec2 uvZ = vec2( p.x * triplanarScaleX, p.y * triplanarScaleY ) + triplanarOffset.xy;
                    
                    uvX.x = 1.0 - uvX.x;    // left/right border horizontal mirror
                    uvX.y = 1.0 - uvX.y;    // left/right border horizontal mirror
                    uvY.y = 1.0 - uvY.y;   // front border vertical flip
                    
                    vec4 colX = texture2D(tex, uvX);
                    vec4 colY = texture2D(tex, uvY);
                    vec4 colZ = texture2D(tex, uvZ);
                    
                    return colX * blend_weights.x + colY * blend_weights.y + colZ * blend_weights.z;
                }

Which is perfect for the front view of my project:

But for the rear it dosent work since the system is just mirroring the front view and its not corresponding to where the back should be at…

But since i’m using a DirectionalLight I put that corner in the shadow so its less apparent.

So unless I could manually calculate the position of the texture in the rear and adjust accordingly, I dont see any better idea.

Thanks

New update: I found out I could target different face of my object with the normalize so I added the code and then I was able to align the texture and add an offset, here is the full code:

Shader:

vec4 sampleTriplanarTexture(sampler2D tex, vec3 p, vec3 n, vec3 triplanarOffset, vec3 triplanarOffsetBack, float triplanarScaleX, float triplanarScaleY, float triplanarScaleZ) {
                    vec3 blend_weights = abs(n);
                    vec2 uvX;
                    vec2 uvY;
                    vec2 uvZ;
                    
                    vec4 colX;
                    vec4 colY;
                    vec4 colZ;
                    
                    vec3 N = normalize(n);
                    vec3 V = normalize(vViewPosition);
                    
                    // Decide dominant axis (stable per face)
                    vec3 w = abs(N);
                    bool faceX = w.x > w.y && w.x > w.z;
                    bool faceY = w.y > w.x && w.y > w.z;
                    bool faceZ = w.z > w.x && w.z > w.y;
                    
                    bool back = false;
                    if (faceZ && N.z < 0.0) { 
                        back = true;
                    }
                
                    if (faceX && N.x < 0.0) { 
                        back = true;
                    }
                
                    if (faceY && N.y < 0.0) {
                        back = true;
                    }
                    
                    // Identify cube face by normal
                    if (back == false) { // FRONT
                        blend_weights = normalize(max(vec3(0.001), blend_weights));
                        blend_weights /= (blend_weights.x + blend_weights.y + blend_weights.z);
                        
                        uvX = vec2( p.y * triplanarScaleY, p.z * triplanarScaleZ ) + triplanarOffset.yz;
                        uvY = vec2( p.x * triplanarScaleX, p.z * triplanarScaleZ ) + triplanarOffset.xz;
                        uvZ = vec2( p.x * triplanarScaleX, p.y * triplanarScaleY ) + triplanarOffset.xy;
                        
                        uvX.x = 1.0 - uvX.x;    // left/right border horizontal mirror
                        uvX.y = 1.0 - uvX.y;    // left/right border horizontal mirror
                        uvY.y = 1.0 - uvY.y;   // front border vertical flip
                        
                        colX = texture2D(tex, uvX);
                        colY = texture2D(tex, uvY);
                        colZ = texture2D(tex, uvZ);
                    }else{ //BACK
                        blend_weights = normalize(max(vec3(0.001), blend_weights));
                        blend_weights /= (blend_weights.x + blend_weights.y + blend_weights.z);
                        
                        uvX = vec2( p.y * triplanarScaleY, p.z * triplanarScaleZ ) + triplanarOffset.yz;
                        uvY = vec2( p.x * triplanarScaleX, p.z * triplanarScaleZ ) + triplanarOffset.xz;
                        uvZ = vec2( p.x * triplanarScaleX, p.y * triplanarScaleY ) + triplanarOffset.xy;                        
                        
                        uvX.y = 1.0 - uvX.y;   // left/right border horizontal mirror
                        uvZ.y = 1.0 - uvZ.y;   // front border vertical flip
                        
                        uvZ += vec2(0.0, triplanarOffsetBack.z); //back side offset
                        uvX += vec2(triplanarOffsetBack.z, 0.0); //font side offset
                        
                        colX = texture2D(tex, uvX);
                        colY = texture2D(tex, uvY);
                        colZ = texture2D(tex, uvZ);
                    }

                    return colX * blend_weights.x + colY * blend_weights.y + colZ * blend_weights.z;
                }

And in the animation() function add the coordinate of the back

export function applyTriplanarBottomLeftOffset(mesh, material, options = {}) {
    const autoScale    = options.autoScale ?? false;   // scale texture to fit mesh
    const uniformScale = options.scale     ?? material.userData.ratio;     // manual scale multiplier
    if (!material.userData.shader) {
        console.warn("Shader not yet compiled. Call this AFTER first render.");
        return;
    }
    const shader = material.userData.shader;
    
    // Compute world-space bounding box
    const bbox = new THREE.Box3().setFromObject(mesh);
    const size = bbox.getSize(new THREE.Vector3());
    let min  = bbox.min;     // bottom-left-near corner (world-space!)
    min.x = min.x * uniformScale;
    min.y = min.y * uniformScale;
    min.z = min.z * uniformScale;
    
    // Compute offset for back sides
    const back = size.clone().multiplyScalar(uniformScale);
    
    // Pass to shader
    shader.uniforms.triplanarScaleX = { value: uniformScale };
    shader.uniforms.triplanarScaleY = { value: uniformScale };
    shader.uniforms.triplanarScaleZ = { value: uniformScale };
    shader.uniforms.triplanarOffset.value.copy(min);
    shader.uniforms.triplanarOffsetBack.value.copy(back);
    
    shader.uniforms.triplanarScaleX.value = uniformScale
    shader.uniforms.triplanarScaleY.value = uniformScale
    shader.uniforms.triplanarScaleZ.value = uniformScale
}

Thanks for the input everyone. I hope this code can help someone.