Z-fighing for flip book

I am trying to recreate this react flip https://r3f-animated-book-slider-final.vercel.app/ in threejs, it is going well but I have som z-fighing that is not happening in react and I have no clue how to fix it …

The math is perfect but visually is a mess.

This is how I set the z so it is perfect, I can’t offset it it must be exactly like this so that the math is right, if I offset it it will not look as a book anymore…

mesh.position.z = (-i * this.pageDepth - this.curPage * this.pageDepth);

I can’t share the code on glitch so I paste the code here hoping for some help.

addPages() { // Raycaster this.raycaster = new FWDFB_THREE.Raycaster();
    // Create a new group for the book and add it to the scene.
    this.bookGroup = new FWDFB_THREE.Group();
    this.bookGroup.name = "bookGroup";
    this.scene.add(this.bookGroup);
  
    // Arrays to store page meshes
    this.pagesAR = [];
    this.curPage = 0;
    // Assume texturesAR has pairs of textures (front then back for each page)
    this.totalPages = Math.floor(this.texturesAR.length / 2);
    const pageWidth = this.data.pageWidth;
    const pageHeight = this.data.pageHeight;
    this.pageDepth = this.data.pageDepth; // e.g. 0.003 (a very thin value)
    const pageSegments = 30;
    const segmentWidth = pageWidth / pageSegments;
  
    // Create the book page geometry – a thin box – and translate it so its left edge is at x=0.
    const geometry = new FWDFB_THREE.BoxGeometry(
      pageWidth,
      pageHeight,
      this.pageDepth,
      pageSegments,
      2
    );
    geometry.translate(pageWidth / 2, 0, 0);
  
    // Prepare skinning attributes (the same for every page).
    const positionAttr = geometry.attributes.position;
    const vertex = new FWDFB_THREE.Vector3();
    const skinIndexes = [];
    const skinWeights = [];
    for (let i = 0; i < positionAttr.count; i++) {
      vertex.fromBufferAttribute(positionAttr, i);
      const x = vertex.x;
      let rawIndex = Math.floor(x / segmentWidth);
      rawIndex = Math.min(rawIndex, pageSegments - 2); // clamp so we don’t exceed available bones
      const skinIndex = rawIndex;
      let skinWeight = (x % segmentWidth) / segmentWidth;
      skinIndexes.push(skinIndex, skinIndex + 1, 0, 0);
      skinWeights.push(1 - skinWeight, skinWeight, 0, 0);
    }
    geometry.setAttribute('skinIndex', new FWDFB_THREE.Uint16BufferAttribute(skinIndexes, 4));
    geometry.setAttribute('skinWeight', new FWDFB_THREE.Float32BufferAttribute(skinWeights, 4));
  
    // Define some colors.
    const whiteColor = "#fff";
    const emissiveColor = "#ffffff";
  
    // Create base materials for the non-textured faces.
    const pageMaterials = [
      new FWDFB_THREE.MeshStandardMaterial({ color: whiteColor }),
      new FWDFB_THREE.MeshStandardMaterial({ color: whiteColor }),
      new FWDFB_THREE.MeshStandardMaterial({ color: whiteColor }),
      new FWDFB_THREE.MeshStandardMaterial({ color: whiteColor }),
    ];
  
    // Loop over pages (each page uses a pair of textures: front and back).
    for (let i = 0; i < this.totalPages; i++) {
      // Get front and back textures.
      const frontTexture = this.texturesAR[2 * i].texture;
      const backTexture = this.texturesAR[2 * i + 1].texture;
  
      // Create the front face material.
      const frontMaterial = new FWDFB_THREE.MeshStandardMaterial({
        color: whiteColor,
        map: frontTexture,
        ...(i === 0 ? { roughnessMap: this.roughnessTexture } : { roughness: 0.1 }),
        emissive: emissiveColor,
        emissiveIntensity: 0,
        depthTest: true,
      });
   
  
      // Create the back face material.
      const backMaterial = new FWDFB_THREE.MeshStandardMaterial({
        color: whiteColor,
        map: backTexture,
        ...(i === this.totalPages - 1 ? { roughnessMap: this.roughnessTexture } : { roughness: 0.1 }),
        emissive: emissiveColor,
        emissiveIntensity: 0,
        depthTest: true,
      
      });
    
  
      // Combine base materials with our front and back materials.
      const materials = [
        ...pageMaterials,
        frontMaterial,
        backMaterial,
      ];
  
      // Create bones for the skinned mesh (one per segment).
      const bones = [];
      for (let j = 0; j < pageSegments; j++) {
        const bone = new FWDFB_THREE.Bone();
        bones.push(bone);
        bone.position.x = (j === 0) ? 0 : segmentWidth;
        if (j > 0) bones[j - 1].add(bone);
      }
      const skeleton = new FWDFB_THREE.Skeleton(bones);
  
      // Create the skinned mesh.
      const mesh = new FWDFB_THREE.SkinnedMesh(geometry, materials);
      mesh.castShadow = true;
      mesh.receiveShadow = true;
      mesh.frustumCulled = false;
  
      // Position pages using your desired formula.
      // Here we use:
      mesh.position.z = (-i * this.pageDepth - this.curPage * this.pageDepth);
     
      // Add the root bone and bind the skeleton.
      mesh.add(skeleton.bones[0]);
      mesh.bind(skeleton);
  
      // Store and add the mesh.
      this.pagesAR.push({
        mesh,
        front: this.texturesAR[2 * i],
        back: this.texturesAR[2 * i + 1],
      });
      this.bookGroup.add(mesh);
    }
  }

After hours of typing ChatGPT that almost made me throw my monitor out the window he just spit this line logarithmicDepthBuffer: true, ot of nowhere and it fixes things…

I notice your camera’s clip range is very wide (0.0001 to 1000). Since the book is always about 5 units away from the camera, you’re throwing away a lot of the depth buffer’s precision, which you need to distinguish between the pages.

You can just tighten up the clip range… this seems to fix the z-fighting:
this.camera = new PerspectiveCamera(70, this.width / this.height, 0.5, 10);

The range of depth in your scene is very small and totally predictable. Using log depth is obviously working, it’s just overkill and an “oddball” setup here. I don’t think there’d be important performance penalties, but it invites “weirdness” as you continue to work on the scene. (in other words, gives me the heebeejeebees)

I’d use the default depth buffer setting, and just set an appropriate clip range for your camera until you have a reason to do otherwise.

Note that every “order of magnitude” you cover with your camera clip has a depth precision cost. So the resolution of a 0.1/10 clip is about 1000 times finer much more well behaved (see my next post below) than 0.0001/10, at the expense of rendering a narrower range.

Thank you for the tip!

You’re welcome @Tibi! Glad to help.

Your question actually sent me down a bit of a rabbit hole learning more about the depth buffer. I thought I understood how it worked, but the default (non-logarithmic) depth buffer is actually way more subtle than I knew.

TL;DR

Keep your far value no more than 100 times your near value, and you probably won’t ever have z-fighting issues. You only need to use logarithmic when your farthest objects are many thousands of times more distant than your closest objects.

Details

I thought the depth buffer simply stored a number that was the linear proportional value between near and far. For example, if near and far are set to 10 and 20, and an object is at 15 from the camera, I imagined the depth buffer would store a value something like 0.5 (or equivalent) because 15 is halfway between near and far.

For me, this was enough to explain why reducing the range between near and far planes often resolves z-fighting. The renderer needs to be able to accurately resolve which polygons are in front or behind, especially when they are close together. Since the depth value can only have a limited amount of precision, and you’re spreading it over a large distance, then there aren’t always enough possible depth values. Right?

It turns out, this is totally true. And it’s enough detail to make sense of z-fighting, and probably fix it most of the time. But it’s far from the whole story.

In reality, the value that is stored is not just a simple linear proportion like that. For reasons that are above my paygrade (math), it’s actually a more complex “hyperbolic” value, apparently because that’s more convenient for an efficient renderer to work with.

I built a desmos visualization of the hyperbolic depth buffer to help myself understand how it actually works. I grabbed these formulas from technical descriptions of how realtime renderers typically use the depth buffer, so I’m pretty sure it’s correct (but would love to be corrected). Please, take a look, it’s fun I promise!

The graph is a side view of your scene. The camera is at the origin on the left and your rendered scene objects live inside the shaded blue region. The green lines indicate which values the depth buffer can represent. Z-fighting happens when the density of these green lines is too low to reliably tell which polygons are in front.

The a/b params are near/far. The w param sets the precision to show on the graph. You can show a precision of up to 100, but it’s worth remembering that, the true number is in the tens of thousands or more (at least 16-bit, but usually 24 or 32 bit).

Two things to try:

Change the near/far (a/b) to be something like 8/10. Notice how tightly packed the green lines have become. This represents a high depth precision, so Z-fighting is unlikely. But your entire scene content must fit within this tight band. Depending on your work, this can be inconveniently restrictive.

Now reduce the near (a) to be a much small number, like 1, but leave the far plane at 10. This is a much wider range, so it’s more convenient to use. But notice how the green lines are spread out across that wider range, representing a reduction in precision.

Perhaps even more interesting than that is how the distribution of the green lines changes. As the range increases (proportionally), the precision becomes increasingly biased toward the near plane. Even with just a 10x difference between near and far, more than 50% of the precision lives in the frontmost 10% of the depth buffer.

This effect is magnified with each order of magnitude you add between near/far. Bringing the near to 0.1, the front 10% contains over 90% of the precision. And so on.

Luckily, the depth buffer stores a dizzying amount of precision overall. So in practice, there’s usually still plenty of precision at the back, even in cases like this.

So, as a general guideline, it’s probably a good practice to keep your far value no more than 100 times the near value (such as 0.1/10 or 3/300). That’s a completely arbitrary advice, but should keeps your camera’s precision very high and quite even, probably without being too inconvenient.

If you’re working with scenes where you need to render objects that are very near and very far, such as when distances need to be more than 10000x different, you should absolutely use the logarithmic depth buffer.

I finished the product, thank you for your help 3D Flip Book

Very nice! This is great work. I really like the detailed polish you’ve built in. The page curling, the camera pulling back during turns, even the watermark logos… etc.

I took a bit of a peek at how you’ve set up the rendering. It’s interesting to see you’re using SMAA. It is subtle, but seems to be working well. There are a couple other shader passes in the composer that seem to be doing tonemapping-ish things.

Are you in a position to say much about how you achieved this? I’m sure plenty folks here would be interested in a deep dive.

PS, some tiny issues you may be interested in:

  • The front and back page animations in particular seem to run with a significantly lower framerate. Not sure what would be causing that but it’s probably not render load, because jumping across many pages runs very smoothly. Is there something special about front and back states?
  • Sometimes the canvas doesn’t have the correct size, which clips the left side of the render. This is resolved by resizing the browser. I am testing on Chrome on Mac. Hard to reproduce, but it’s happened a few times.
  • There’s a part of the lighting that shows on the right page that is bright enough to obscure content. This would make a “real” page hard to read, but perhaps this is not a problem for this demo.

Thank you for the feedback, I appreciate it!

It’s complicated to go through the entire process.
Regarding rendering: yes, I use post-processing with EffectComposer to enable anti-aliasing—without it, everything looks really bad no matter what settings I tried. I also use a few extra passes for fun, such as mouse distortion and mouse ripple effects.

As for performance, the issue comes from how pages and textures are loaded. Textures are added one by one, and I haven’t found a way to fully fix this. Unfortunately, this seems to be a weakness of Three.js: when textures are loaded and used, performance suffers, even if they are preloaded and added to the scene individually.

If you want the files, send me a private message.