iOS WebGL 纹理渲染中的缺陷

Flaw in iOS WebGL texture rendering

这个使用 three.js 库的 WebGL 纹理渲染的简单测试:

// Canvas dimensions

canvasW = Math.floor(0.9*window.innerWidth);
canvasH = Math.floor(0.75*canvasW);
cAR = canvasW / canvasH;
canvasWrapper = document.getElementById('canvasWrapper');
canvasWrapper.style.width=canvasW+'px';
canvasWrapper.style.height=canvasH+'px';

// Renderer

renderer = new THREE.WebGLRenderer({antialias: true});
renderer.setPixelRatio(window.devicePixelRatio);
console.log("Renderer pixel ratio = "+window.devicePixelRatio);
renderer.setSize(canvasW, canvasH);
canvas = renderer.domElement;
canvasWrapper.appendChild(canvas);

// Set up camera

cameraDist = 24;
camera = new THREE.PerspectiveCamera(25, cAR, 0.01, 1000);
cameraAngle = 0;
camera.position.x = cameraDist*Math.sin(cameraAngle);
camera.position.y = 0.3*cameraDist;
camera.position.z = cameraDist*Math.cos(cameraAngle);
camera.lookAt(new THREE.Vector3(0,0,0));

// Set up scene, consisting of texture-tiled ground

scene = new THREE.Scene();
groundWidth = 1000;
groundMaterial = null;
groundGeom = new THREE.PlaneGeometry(groundWidth,groundWidth);
groundGeom.rotateX(-Math.PI/2);
groundMesh = new THREE.Mesh(groundGeom, groundMaterial || new THREE.MeshBasicMaterial());
scene.add(groundMesh);
//window.requestAnimationFrame(draw);

// Insert texture once it has loaded

function setGroundTexture(texture)
{
  groundTexture = texture;
  groundTexture.wrapS = THREE.RepeatWrapping;
  groundTexture.wrapT = THREE.RepeatWrapping;
  groundTexture.repeat.set(groundWidth, groundWidth);
  groundTexture.anisotropy = renderer.getMaxAnisotropy();
  console.log("Texture anisotropy = "+groundTexture.anisotropy);
  groundMaterial = new THREE.MeshBasicMaterial({map: groundTexture});
  if (groundMesh)
  {
    groundMesh.material = groundMaterial;
    window.requestAnimationFrame(draw);
  };
}

// Start texture loading

//new THREE.TextureLoader().load("Texture.png", setGroundTexture, function (xhr) {}, function (xhr) {});
setGroundTexture(makeTexture());

// Render a frame

function draw()
{
  renderer.render(scene, camera);
}

// -------

function makeTexture() {
  var ctx = document.createElement("canvas").getContext("2d");
  ctx.canvas.width = 256;
  ctx.canvas.height = 256;
  ctx.fillStyle = "rgb(238, 238, 238)";
  ctx.fillRect(0, 0, 256, 256);
  ctx.fillStyle = "rgb(208, 208, 208)";
  ctx.fillRect(0, 0, 128, 128);
  ctx.fillRect(128, 128, 128, 128);
  for (var y = 0; y < 2; ++y) {
    for (var x = 0; x < 2; ++x) {
      ctx.save();
      ctx.translate(x * 128 + 64, y * 128 + 64);
      ctx.lineWidth = 3;
      ctx.beginPath();
      var radius = 50;
      ctx.moveTo(radius, 0);
      for (var i = 0; i <= 6; ++i) {
        var a = i / 3 * Math.PI;
        ctx.lineTo(Math.cos(a) * radius, Math.sin(a) * radius);
      }
      ctx.stroke();
      ctx.restore();
    }
  }
  var tex = new THREE.Texture(ctx.canvas);
  tex.needsUpdate = true;
  return tex;
}
canvas, #canvasWrapper {margin-left: auto; margin-right: auto;}
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r78/three.js"></script>
<div id="canvasWrapper"></div>

在我试过的桌面浏览器上呈现完美,但在 iPad 上呈现时非常模糊,如页面下方的屏幕截图所示。

桌面

iPad

在这两种情况下,纹理的各向异性均为 16(渲染器支持的最大值)。用于纹理的图像尺寸为 256 × 256(2 的幂,这是重复纹理所必需的),将其变大或变小都不能解决问题。

质地:

我正在设置渲染器的像素比以匹配浏览器 window,这意味着桌面系统为 1,iPad 的视网膜显示器为 2。这种方法通常可以为渲染的其他方面提供最佳结果,并且在任何情况下将 iPad 上的像素比率设置为 1 而不是 2,都不会改善纹理的外观。

所以我的问题是:这是 iOS WebGL 中的一个错误,我不得不忍受,还是我可以在自己的代码中进行一些调整以在 [=52] 上获得更好的结果=] 设备?

编辑: 这个 three.js demo page 在 iPad 上的呈现效果也比在桌面浏览器上的清晰度低得多,而且演示使用与我自己的代码相同的通用方法,这表明无论我遗漏了什么技巧,都不是简单明了的东西。

我无法完全解释问题的根源,但我找到了一个解决方法,表明原因是某种数值精度的下降,我猜在 GPU 中,这不是每个 iPad 显卡都会出现。

解决方法包括将地面的平面几何图形分割成多个正方形的网格,地面最初只是一个正方形(three.js 大概分为 2 个三角形)。据推测,这会改变对象上的 (u,v) 坐标和纹理坐标 运行 的方式,以对抗 GPU 中浮点精度的限制。此外,将地面的大小从 1000 减小到 200 也有帮助。

烦人的是平面几何中所有这些额外面的开销,即使它们在指定形状时完全多余。

无论如何,结果在我的桌面浏览器上看起来完全一样,但在我的 iPad 4.

上要好得多

编辑: 经过更仔细的测试,我认为细分 THREE.PlaneGeometry 没有任何区别,它只是减少了 的整体大小 有帮助的平铺平面。事实上,通过使平铺平面的尺寸 足够大 ,当尺寸仅为 1000 时 iPad 4 上的任何限制都可以在我的 iMac 上达到当大小为 80,000 时,如代码片段的第二个版本所示。 (纹理在 50,000 左右开始降级,但 80,000 使失真变得不容错过。)显然,没有真正的应用程序需要用 50,000 x 50,000 个纹理副本来平铺表面,但每个方向有几百个,这就是iPad4开始有问题,不奢侈

第一版代码片段,修复了 iPad 4:

上的问题

// Canvas dimensions

canvasW = Math.floor(0.9*window.innerWidth);
canvasH = Math.floor(0.75*canvasW);
cAR = canvasW / canvasH;
canvasWrapper = document.getElementById('canvasWrapper');
canvasWrapper.style.width=canvasW+'px';
canvasWrapper.style.height=canvasH+'px';

// Renderer

renderer = new THREE.WebGLRenderer({antialias: true});
renderer.setPixelRatio(window.devicePixelRatio);
console.log("Renderer pixel ratio = "+window.devicePixelRatio);
renderer.setSize(canvasW, canvasH);
canvas = renderer.domElement;
canvasWrapper.appendChild(canvas);

// Set up camera

cameraDist = 24;
camera = new THREE.PerspectiveCamera(25, cAR, 0.01, 1000);
cameraAngle = 0;
camera.position.x = cameraDist*Math.sin(cameraAngle);
camera.position.y = 0.3*cameraDist;
camera.position.z = cameraDist*Math.cos(cameraAngle);
camera.lookAt(new THREE.Vector3(0,0,0));

// Set up scene, consisting of texture-tiled ground

scene = new THREE.Scene();
// groundWidth = 1000;
// Reduce overall size of ground
groundWidth = 200;
groundMaterial = null;
// groundGeom = new THREE.PlaneGeometry(groundWidth,groundWidth);
// Split plane geometry into a grid of smaller squares
groundGeom = new THREE.PlaneGeometry(groundWidth,groundWidth,20,20);
groundGeom.rotateX(-Math.PI/2);
groundMesh = new THREE.Mesh(groundGeom, groundMaterial || new THREE.MeshBasicMaterial());
scene.add(groundMesh);
//window.requestAnimationFrame(draw);

// Insert texture once it has loaded

function setGroundTexture(texture)
{
  groundTexture = texture;
  groundTexture.wrapS = THREE.RepeatWrapping;
  groundTexture.wrapT = THREE.RepeatWrapping;
  groundTexture.repeat.set(groundWidth, groundWidth);
  groundTexture.anisotropy = renderer.getMaxAnisotropy();
  console.log("Texture anisotropy = "+groundTexture.anisotropy);
  groundMaterial = new THREE.MeshBasicMaterial({map: groundTexture});
  if (groundMesh)
  {
    groundMesh.material = groundMaterial;
    window.requestAnimationFrame(draw);
  };
}

// Start texture loading

//new THREE.TextureLoader().load("Texture.png", setGroundTexture, function (xhr) {}, function (xhr) {});
setGroundTexture(makeTexture());

// Render a frame

function draw()
{
  renderer.render(scene, camera);
}

// -------

function makeTexture() {
  var ctx = document.createElement("canvas").getContext("2d");
  ctx.canvas.width = 256;
  ctx.canvas.height = 256;
  ctx.fillStyle = "rgb(238, 238, 238)";
  ctx.fillRect(0, 0, 256, 256);
  ctx.fillStyle = "rgb(208, 208, 208)";
  ctx.fillRect(0, 0, 128, 128);
  ctx.fillRect(128, 128, 128, 128);
  for (var y = 0; y < 2; ++y) {
    for (var x = 0; x < 2; ++x) {
      ctx.save();
      ctx.translate(x * 128 + 64, y * 128 + 64);
      ctx.lineWidth = 3;
      ctx.beginPath();
      var radius = 50;
      ctx.moveTo(radius, 0);
      for (var i = 0; i <= 6; ++i) {
        var a = i / 3 * Math.PI;
        ctx.lineTo(Math.cos(a) * radius, Math.sin(a) * radius);
      }
      ctx.stroke();
      ctx.restore();
    }
  }
  var tex = new THREE.Texture(ctx.canvas);
  tex.needsUpdate = true;
  return tex;
}
canvas, #canvasWrapper {margin-left: auto; margin-right: auto;}
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r78/three.js"></script>
<div id="canvasWrapper"></div>

代码片段的第二个版本,破坏了 2007 iMac 上的纹理:

// Canvas dimensions

canvasW = Math.floor(0.9*window.innerWidth);
canvasH = Math.floor(0.75*canvasW);
cAR = canvasW / canvasH;
canvasWrapper = document.getElementById('canvasWrapper');
canvasWrapper.style.width=canvasW+'px';
canvasWrapper.style.height=canvasH+'px';

// Renderer

renderer = new THREE.WebGLRenderer({antialias: true});
renderer.setPixelRatio(window.devicePixelRatio);
console.log("Renderer pixel ratio = "+window.devicePixelRatio);
renderer.setSize(canvasW, canvasH);
canvas = renderer.domElement;
canvasWrapper.appendChild(canvas);

// Set up camera

cameraDist = 24;
camera = new THREE.PerspectiveCamera(25, cAR, 0.01, 1000);
cameraAngle = 0;
camera.position.x = cameraDist*Math.sin(cameraAngle);
camera.position.y = 0.3*cameraDist;
camera.position.z = cameraDist*Math.cos(cameraAngle);
camera.lookAt(new THREE.Vector3(0,0,0));

// Set up scene, consisting of texture-tiled ground

scene = new THREE.Scene();
// groundWidth = 1000;
// Increase the size of the plane to trigger the problem
groundWidth = 80000;
groundMaterial = null;
groundGeom = new THREE.PlaneGeometry(groundWidth,groundWidth);
groundGeom.rotateX(-Math.PI/2);
groundMesh = new THREE.Mesh(groundGeom, groundMaterial || new THREE.MeshBasicMaterial());
scene.add(groundMesh);
//window.requestAnimationFrame(draw);

// Insert texture once it has loaded

function setGroundTexture(texture)
{
  groundTexture = texture;
  groundTexture.wrapS = THREE.RepeatWrapping;
  groundTexture.wrapT = THREE.RepeatWrapping;
  groundTexture.repeat.set(groundWidth, groundWidth);
  groundTexture.anisotropy = renderer.getMaxAnisotropy();
  console.log("Texture anisotropy = "+groundTexture.anisotropy);
  groundMaterial = new THREE.MeshBasicMaterial({map: groundTexture});
  if (groundMesh)
  {
    groundMesh.material = groundMaterial;
    window.requestAnimationFrame(draw);
  };
}

// Start texture loading

//new THREE.TextureLoader().load("Texture.png", setGroundTexture, function (xhr) {}, function (xhr) {});
setGroundTexture(makeTexture());

// Render a frame

function draw()
{
  renderer.render(scene, camera);
}

// -------

function makeTexture() {
  var ctx = document.createElement("canvas").getContext("2d");
  ctx.canvas.width = 256;
  ctx.canvas.height = 256;
  ctx.fillStyle = "rgb(238, 238, 238)";
  ctx.fillRect(0, 0, 256, 256);
  ctx.fillStyle = "rgb(208, 208, 208)";
  ctx.fillRect(0, 0, 128, 128);
  ctx.fillRect(128, 128, 128, 128);
  for (var y = 0; y < 2; ++y) {
    for (var x = 0; x < 2; ++x) {
      ctx.save();
      ctx.translate(x * 128 + 64, y * 128 + 64);
      ctx.lineWidth = 3;
      ctx.beginPath();
      var radius = 50;
      ctx.moveTo(radius, 0);
      for (var i = 0; i <= 6; ++i) {
        var a = i / 3 * Math.PI;
        ctx.lineTo(Math.cos(a) * radius, Math.sin(a) * radius);
      }
      ctx.stroke();
      ctx.restore();
    }
  }
  var tex = new THREE.Texture(ctx.canvas);
  tex.needsUpdate = true;
  return tex;
}
canvas, #canvasWrapper {margin-left: auto; margin-right: auto;}
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r78/three.js"></script>
<div id="canvasWrapper"></div>

Greg Egan 的观察很有道理。如果您不仅细分平面而且平铺 UV 坐标,那么它们会重复而不是使用可能修复它的大量数字。

// Canvas dimensions

canvasW = Math.floor(0.9*window.innerWidth);
canvasH = Math.floor(0.75*canvasW);
cAR = canvasW / canvasH;
canvasWrapper = document.getElementById('canvasWrapper');
canvasWrapper.style.width=canvasW+'px';
canvasWrapper.style.height=canvasH+'px';

// Renderer

renderer = new THREE.WebGLRenderer({antialias: true});
renderer.setPixelRatio(window.devicePixelRatio);
console.log("Renderer pixel ratio = "+window.devicePixelRatio);
renderer.setSize(canvasW, canvasH);
canvas = renderer.domElement;
canvasWrapper.appendChild(canvas);

// Set up camera

cameraDist = 24;
camera = new THREE.PerspectiveCamera(25, cAR, 0.01, 1000);
cameraAngle = 0;
camera.position.x = cameraDist*Math.sin(cameraAngle);
camera.position.y = 0.3*cameraDist;
camera.position.z = cameraDist*Math.cos(cameraAngle);
camera.lookAt(new THREE.Vector3(0,0,0));

// Set up scene, consisting of texture-tiled ground

scene = new THREE.Scene();
// groundWidth = 1000;
// Reduce overall size of ground
groundWidth = 200;
groundMaterial = null;
// groundGeom = new THREE.PlaneGeometry(groundWidth,groundWidth);
// Split plane geometry into a grid of smaller squares
//groundGeom = new THREE.PlaneGeometry(groundWidth,groundWidth,20,20);
var groundGeom = new THREE.BufferGeometry();

var quads = groundWidth * groundWidth;
var positions = new Float32Array( quads * 6 * 3 );
var normals = new Float32Array( quads * 6 * 3 );
var texcoords = new Float32Array( quads * 6 * 2 );

for (var yy = 0; yy < groundWidth; ++yy) {
  for (var xx = 0; xx < groundWidth; ++xx) {
    var qoff = (yy * groundWidth + xx) * 6;
    var poff = qoff * 3;
    var x = xx - groundWidth / 2;
    var y = yy - groundWidth / 2;
    positions[poff +  0] = x;
    positions[poff +  1] = y;
    positions[poff +  2] = 0;
    
    positions[poff +  3] = x + 1;
    positions[poff +  4] = y;
    positions[poff +  5] = 0;
    
    positions[poff +  6] = x;
    positions[poff +  7] = y + 1;
    positions[poff +  8] = 0;

    positions[poff +  9] = x;
    positions[poff + 10] = y + 1;
    positions[poff + 11] = 0;
    
    positions[poff + 12] = x + 1;
    positions[poff + 13] = y;
    positions[poff + 14] = 0;
    
    positions[poff + 15] = x + 1;
    positions[poff + 16] = y + 1;
    positions[poff + 17] = 0;
    
    normals[poff +  0] = 0;
    normals[poff +  1] = 1;
    normals[poff +  2] = 0;
    
    normals[poff +  3] = 0;
    normals[poff +  4] = 1;
    normals[poff +  5] = 0;
    
    normals[poff +  6] = 0;
    normals[poff +  7] = 1;
    normals[poff +  8] = 0;

    normals[poff +  9] = 0;
    normals[poff + 10] = 1;
    normals[poff + 11] = 0;
    
    normals[poff + 12] = 0;
    normals[poff + 13] = 1;
    normals[poff + 14] = 0;
    
    normals[poff + 15] = 0;
    normals[poff + 16] = 1;
    normals[poff + 17] = 0;
    
    var toff = qoff * 2;

    texcoords[toff +  0] = 0;
    texcoords[toff +  1] = 0;
    
    texcoords[toff +  2] = 1;
    texcoords[toff +  3] = 0;
    
    texcoords[toff +  4] = 0;
    texcoords[toff +  5] = 1;

    texcoords[toff +  6] = 0;
    texcoords[toff +  7] = 1;
    
    texcoords[toff +  8] = 1;
    texcoords[toff +  9] = 0;
    
    texcoords[toff + 10] = 1;
    texcoords[toff + 11] = 1;
  }
}

groundGeom.addAttribute( 'position', new THREE.BufferAttribute( positions, 3 ) );
groundGeom.addAttribute( 'normal', new THREE.BufferAttribute( normals, 3 ) );
groundGeom.addAttribute( 'uv', new THREE.BufferAttribute( texcoords, 2 ) );

groundGeom.computeBoundingSphere();

groundGeom.rotateX(-Math.PI/2);
groundMesh = new THREE.Mesh(groundGeom, groundMaterial || new THREE.MeshBasicMaterial());
scene.add(groundMesh);
//window.requestAnimationFrame(draw);

// Insert texture once it has loaded

function setGroundTexture(texture)
{
  groundTexture = texture;
  groundTexture.wrapS = THREE.RepeatWrapping;
  groundTexture.wrapT = THREE.RepeatWrapping;
  groundTexture.repeat.set(1, 1);
  groundTexture.anisotropy = renderer.getMaxAnisotropy();
  console.log("Texture anisotropy = "+groundTexture.anisotropy);
  groundMaterial = new THREE.MeshBasicMaterial({map: groundTexture});
  if (groundMesh)
  {
    groundMesh.material = groundMaterial;
    window.requestAnimationFrame(draw);
  };
}

// Start texture loading

//new THREE.TextureLoader().load("Texture.png", setGroundTexture, function (xhr) {}, function (xhr) {});
setGroundTexture(makeTexture());

// Render a frame

function draw()
{
  renderer.render(scene, camera);
}

// -------

function makeTexture() {
  var ctx = document.createElement("canvas").getContext("2d");
  ctx.canvas.width = 256;
  ctx.canvas.height = 256;
  ctx.fillStyle = "rgb(238, 238, 238)";
  ctx.fillRect(0, 0, 256, 256);
  ctx.fillStyle = "rgb(208, 208, 208)";
  ctx.fillRect(0, 0, 128, 128);
  ctx.fillRect(128, 128, 128, 128);
  for (var y = 0; y < 2; ++y) {
    for (var x = 0; x < 2; ++x) {
      ctx.save();
      ctx.translate(x * 128 + 64, y * 128 + 64);
      ctx.lineWidth = 3;
      ctx.beginPath();
      var radius = 50;
      ctx.moveTo(radius, 0);
      for (var i = 0; i <= 6; ++i) {
        var a = i / 3 * Math.PI;
        ctx.lineTo(Math.cos(a) * radius, Math.sin(a) * radius);
      }
      ctx.stroke();
      ctx.restore();
    }
  }
  var tex = new THREE.Texture(ctx.canvas);
  tex.needsUpdate = true;
  return tex;
}
canvas, #canvasWrapper {margin-left: auto; margin-right: auto;}
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r78/three.js"></script>
<div id="canvasWrapper"></div>