How To Make Seamless Custom CubeMap?

I'm currently working on a Three.js project, and I'm aiming to create a seamless cube map. To achieve this, I created six planes and assembled them into a cube. Then, I utilized a shader to generate noise (these are not textures) on the cube's faces.

you can already see the distortion between the cube faces.

I also implemented a process in the shader to undo the transforms for each mesh, which effectively keeps the noise in place, resulting in a cube map-like object as you can see in the first image below.

At first glance, the cube map appears seamless, but upon closer inspection, slight distortions are visible at the edges. This becomes a bigger issue when I attempt to convert it to a normal map, resulting in poor lighting. Regardless of the method I try, I always end up with seams where the edges of each face of the cube touch.

(front bottom edge and bottom top edge distortion):

zoomed-in look, you can see how to start to converge.

Any guidance or solutions to achieve a truly seamless cube map would be highly appreciated. I've looked through various resources, but I couldn't find a comprehensive explanation or tutorial specifically tailored to creating seamless cube maps in Three.js.

After doing some more research, when you get extremely close to a seam it appears that the meshes never actually touch (I don’t why I was under the assumption they did); they don’t share the same position at each face edge. Despite my efforts to position them as close as possible without overlapping, there remains a tiny space between the meshes, as indicated by the dotted white line.

I believe that the discontinuity is likely caused by the noise function lacking the necessary additional information to bridge this gap.

It took some time to put together but here is a working example of my code: EXAMPLE

let camera,scene,mesh,renderer

//-----------noise functions
function permute(){
return  
vec4 permute(vec4 x){return mod(((x*34.0)+1.0)*x, 289.0);}

}
function taylorInvSqrt(){
return     
vec4 taylorInvSqrt(vec4 r){return 1.79284291400159 - 0.85373472095314 * r;}

}
function snoise(){
return 
float snoise3D(vec3 v){
const vec2  C = vec2(1.0/6.0, 1.0/3.0) ;
const vec4  D = vec4(0.0, 0.5, 1.0, 2.0);

vec3 i  = floor(v + dot(v, C.yyy) );
vec3 x0 =   v - i + dot(i, C.xxx) ;

vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min( g.xyz, l.zxy );
vec3 i2 = max( g.xyz, l.zxy );

vec3 x1 = x0 - i1 + 1.0 * C.xxx;
vec3 x2 = x0 - i2 + 2.0 * C.xxx;
vec3 x3 = x0 - 1. + 3.0 * C.xxx;

i = mod(i, 289.0 );
vec4 p = permute( permute( permute(
i.z + vec4(0.0, i1.z, i2.z, 1.0 ))
+ i.y + vec4(0.0, i1.y, i2.y, 1.0 ))
+ i.x + vec4(0.0, i1.x, i2.x, 1.0 ));

float n_ = 1.0/7.0;
vec3  ns = n_ * D.wyz - D.xzx;

vec4 j = p - 49.0 * floor(p * ns.z *ns.z);

vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_ );

vec4 x = x_ *ns.x + ns.yyyy;
vec4 y = y_ *ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);

vec4 b0 = vec4( x.xy, y.xy );
vec4 b1 = vec4( x.zw, y.zw );

vec4 s0 = floor(b0)*2.0 + 1.0;
vec4 s1 = floor(b1)*2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));

vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy ;
vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww ;

vec3 p0 = vec3(a0.xy,h.x);
vec3 p1 = vec3(a0.zw,h.y);
vec3 p2 = vec3(a1.xy,h.z);
vec3 p3 = vec3(a1.zw,h.w);

vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2, p2), dot(p3,p3)));
p0 *= norm.x;
p1 *= norm.y;
p2 *= norm.z;
p3 *= norm.w;

vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
m = m * m;
return 42.0 * dot( m*m, vec4( dot(p0,x0), dot(p1,x1),
dot(p2,x2), dot(p3,x3) ) );
}

}

// -------- Create a custom shader
const vertexShader = 
varying vec4 worldPosition;
uniform  int ignoreFront; //<---- this is just a flag to ignore calling the undoTransfroms function for the front face
uniform  mat4 rm;
uniform  vec3 undoPoition;

vec3 undoTransfroms(vec3 v, mat4 rm){
vec4 j =  (rm*vec4(v,1.));
j.z += undoPoition.z;
j.y += undoPoition.y;
j.x += undoPoition.x;
return j.xyz;
}

void main() {
worldPosition = modelMatrix * vec4(position, 1.0);
vec3 newPosition = position;
if(ignoreFront == 1){
newPosition = undoTransfroms( position,  rm);
}
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);

}
;

const fragmentShader = 
uniform vec3 center;
varying vec4 worldPosition;

${taylorInvSqrt()}${permute()}
\${snoise()}

void main() {
float n = snoise3D(normalize(worldPosition.xyz-center));
gl_FragColor = vec4(vec3(n), 1.0);
}
;

//--------build mesh
function createPlaneMesh(x, y, z, rotationX, rotationY, rotationZ, uniforms) {
// Create a plane geometry
const planeGeometry = new THREE.PlaneGeometry(10, 10, 10, 10);
uniforms.center = {value:new THREE.Vector3(0,0,-5)}
uniforms: uniforms,
});
// Create the plane mesh
const planeMesh = new THREE.Mesh(planeGeometry, planeMaterial);
// Set the position of the mesh
planeMesh.position.set(x, y, z);
// Set the rotation of the mesh
planeMesh.rotation.set(rotationX, rotationY, rotationZ);
return planeMesh;
}
//------------

function init(){
//-----------Basic setUp
renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.setAnimationLoop( animation );
document.body.appendChild( renderer.domElement );
renderer.setClearColor( 'white' )

camera = new THREE.PerspectiveCamera( 70, window.innerWidth / window.innerHeight, 0.01, 100 );
camera.position.z = 20;
var controls = new THREE.OrbitControls(camera, renderer.domElement);
scene = new THREE.Scene();

/********
- set creat mesh
- set transfroms
- set unifroms for undoing of transfoms
*********/
let widthHeight = 10

//------------front
let frontUnifrom = {ignoreFront:{value:0}}
let front = createPlaneMesh(0,0,0,0,0,0,frontUnifrom)
//-----------back
let bz    = -widthHeight
let bry   = Math.PI
var undorotationMatrix = new THREE.Matrix4();
undorotationMatrix.makeRotationY(-bry);
let backUnifrom = {rm:{value:undorotationMatrix},undoPoition:{value:new THREE.Vector3(bz*2,0,bz)},ignoreFront:{value:1}}
let back  = createPlaneMesh(0,0,bz,0,bry,0,backUnifrom)
//---------right
let rz    = -(widthHeight)/2;
let rx    =  (widthHeight)/2;
let rry   =  Math.PI/2;
var undorotationMatrix = new THREE.Matrix4();
undorotationMatrix.makeRotationY(-rry);
let rightUnifrom = {rm:{value:undorotationMatrix},undoPoition:{value:new THREE.Vector3(-rx,0,-rz)},ignoreFront:{value:1}}
let right = createPlaneMesh(rx,0,rz,0,rry,0,rightUnifrom)
//---------left
let lz    =  -(widthHeight)/2;
let lx    =  -(widthHeight)/2;
let lry   =  -Math.PI/2;
var undorotationMatrix = new THREE.Matrix4();
undorotationMatrix.makeRotationY(-lry);
let leftUnifrom = {rm:{value:undorotationMatrix},undoPoition:{value:new THREE.Vector3(-lx,0,-lz)},ignoreFront:{value:1}}
let left  = createPlaneMesh(lx,0,lz,0,lry,0,leftUnifrom)
//--------top
let tz    =  -(widthHeight)/2;
let ty    =  (widthHeight)/2;
let trx   =  -Math.PI/2;
var undorotationMatrix = new THREE.Matrix4();
undorotationMatrix.makeRotationX(-trx);
let topUnifrom = {rm:{value:undorotationMatrix},undoPoition:{value:new THREE.Vector3(0,-ty,-tz)},ignoreFront:{value:1}}
let top  = createPlaneMesh(0,ty,tz,trx,0,0,topUnifrom)
//---------bottom
let boz   =  -(widthHeight)/2;
let boy   =  -(widthHeight)/2;
let borx  =  Math.PI/2;
var undorotationMatrix = new THREE.Matrix4();
undorotationMatrix.makeRotationX(-borx);
let boUnifrom = {rm:{value:undorotationMatrix},undoPoition:{value:new THREE.Vector3(0,-boy,-boz)},ignoreFront:{value:1}}
let bo    = createPlaneMesh(0,boy,boz,borx,0,0,boUnifrom)
}

// animation
init()

function animation( time ) {
renderer.render( scene, camera );

}
 <script src="https://cdn.jsdelivr.net/npm/[email protected]/build/three.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/controls/OrbitControls.js"></script> 

• This looks similar to mistakes one can make with edges of a texture due to there being no texels adjacent to the outside edge of the displayed region to interpolate with, but you say you're using a shader and not a texture. We're going to need to see the code of the shader to identify the problem. Commented Jul 22, 2023 at 17:21
• @KevinReid see the bottom of the updated post for the example link. Commented Jul 22, 2023 at 19:21
• This looks very similar to this recent question. If the two of you are team members working on the same project, or one user making use of two usernames, I recommend consolidating these to a single post. The context helps attract better answers, and keeps relevant discussion and the best solutions all in one place, rather than scattered between two unrelated links, which can leave confusing dead-ends for future searches. Commented Jul 24, 2023 at 17:31

Firstly, the first derivative discontinuities you observe in your heightmap texture are just an artifact of flattening the spherical/angular domain into six gnomonic projection "charts". Because the projection changes abruptly at the edge of a cube, the gradients have an apparent change in direction there too. But once this is re-projected to a sphere, that kink vanishes, as the projection for reading the cubemap exactly cancels out the projection used for writing it. So as long as your ultimate intent is to render this texture on a sphere or otherwise in the angular domain (like a skybox), these projection artifacts are safe to ignore.

As for how to avoid problems when trying to convert this greyscale heightmap into normals, my advice is: don't.

Calculate your normals analytically as part of evaluating the noise function, rather than trying to infer them later by taking finite differences of the heightmap.

This is cheaper and gives higher-quality normals, and saves you from a world of edge- and corner-case grief in trying to get sample-based values to agree across all the tangent space seams.

You can find examples of how to compute derivatives of a gradient noise field simultaneously as you sample it in this previous answer (for Simplex noise) or in this article from Inigo Quilez.

This will give you the direction in 3D space in which the noise field is getting brighter fastest. Zero-out the radial component to get the rate of change along the sphere's surface at that point. The magnitude tells you the slope of the height function "If you slide one unit (run) in this direction, height value increases by rise = length(gradient)". Multiply that by your height scale to control the intensity of the normals.

Here's some example code showing how we can use these analytical gradients to compute a normal vector in world space:

vec3 sampleDir = normalize(worldPos - center);
vec4 noise = noised(sampleDir); // Using IQ's "noise-with-derivative"

// IQ's version returns noise value in x, gradient in yzw.
float height = noise.x;

// Zero out component perpendicular to the sphere.

// Normal vector tilts away from "uphill" direction.
float3 normal = normalize(sampleDir - onSphere * heightScale);


The advantage of forming this normal in world space is that all adjacent cube faces will agree on the value of the normal where they meet. The downside is that you lose some precision when storing object/world-space normals, you can't compress the texture as easily, and it's harder to blend the result with detail normal maps or scale the intensity of the normals after the fact.

If you prefer to work in tangent space, you can use the TBN matrix at that point of the cube-sphere to transform the world space result into the tangent space for the plane. As long as you use the same tangent space for writing and for reading, you'll get back to the same "net" normal after the fact, and you should still have seamless agreement between adjacent faces. But if you store the normal map in tangent space, don't store/read it as a cubemap (adjacent faces don't always agree about the orientation of the tangent space, causing ugly interpolation where they meet).

• Following your recommendation to calculate the normals analytically produces great results. Thank you. However, simply offsetting the sampleDir (world space position) in any direction before passing it to the noise function seems to result in incorrect gradients. I will mark this question as answered and post my second question here. Commented Jul 30, 2023 at 0:51
• Don't add 50 to sampleDir as used in normal reconstruction. You can shift the noise location like float3 shiftedSample = sampleDir + shift; float4 noise = noised(shiftedSample);, but you must use the original unshifted sampleDir for the onSphere and normal lines. (It has to be a point on the unit sphere centered at the origin, not a point 50 units to the right of that). Commented Jul 30, 2023 at 2:25
• I'm trying your other recommendation to transform the normals from world space to tangent space. But I'm unsure if I'm using my TBN Martix correctly. There seems to be little to no changes between the world space normal and tangent space normal. lines 86-88 is the construction of the TBN and line 151 is where I try to use it to convert the world space normal to tangent space. EXAMPLE Commented Jul 31, 2023 at 0:45
• Looks like something to post a new question about. I won't have access to a computer until later this week, but other users may be able to help you. Commented Jul 31, 2023 at 2:30
• Hi again, I wanted to know if it would be possible to convert a arbitrary tangent Space normal map to world space instead of the world to tangent. so it matches the normals of those produced by the noise derivative? Commented Aug 6, 2023 at 19:57