wandres.dev
PUNTOS Y LÍNEAS · Sistemas de partículas y trazos

Por qué no puedes engordar una línea

El ancho de línea de WebGL está clavado en un píxel en la práctica, y la única salida es construir geometría: cómo funciona Line2 por dentro y qué cuesta.

⏱ 17 min

Es la primera decepción de todo el que dibuja una línea en 3D: pones linewidth: 4, la línea sigue midiendo un píxel, buscas en internet y encuentras hilos de doce años sin una respuesta clara. La respuesta corta es que WebGL nunca prometió líneas gruesas. La respuesta larga explica por qué, y la solución de verdad implica dejar de dibujar líneas y empezar a dibujar triángulos.

🎯 Al terminar esta lección sabrás
  • Explicar por qué LineBasicMaterial.linewidth no tiene efecto en WebGL ni en WebGPU.
  • Comprobar en el propio dispositivo el rango de ancho de línea soportado.
  • Describir cómo Line2 convierte cada segmento en un quad instanciado.
  • Decidir entre línea gruesa por geometría, tubo y sprite según el caso.

Un píxel, y no hay más

La documentación de LineBasicMaterial en r184 lo dice sin rodeos: linewidth solo funciona con SVGRenderer, y tanto WebGL como WebGPU ignoran la propiedad y dibujan siempre líneas de un píxel de ancho. MeshNormalMaterial y MeshDepthMaterial repiten la misma nota sobre wireframeLinewidth. No es un olvido de Three.js: es que abajo no hay nada que llamar.

La especificación de OpenGL ES, de la que WebGL desciende, permite que una implementación soporte únicamente el ancho 1.0 para líneas con antialiasing. El perfil moderno de OpenGL de escritorio directamente marcó las líneas anchas como obsoletas, y Direct3D nunca las tuvo, así que ANGLE, la capa que traduce WebGL a D3D en Windows, no puede ofrecerlas aunque quisiera. El resultado es que en la práctica totalidad de los navegadores el rango soportado es exactamente de uno a uno:

const gl = renderer.getContext();
console.log(gl.getParameter(gl.ALIASED_LINE_WIDTH_RANGE)); // casi siempre [1, 1]

Compruébalo en tu máquina. Ese array de dos elementos es toda la explicación que existe.

Dibujar una línea con triángulos

Si la primitiva no sirve, se sustituye por geometría. La idea es vieja y sencilla: cada segmento de la polilínea se convierte en un rectángulo perpendicular a la dirección del segmento, y la anchura de ese rectángulo se calcula en el vertex shader para que mida lo que tú pidas medido en píxeles de pantalla, no en unidades del mundo. Los extremos se rematan con una tapa redonda y las uniones entre segmentos se rellenan para que no se abran al doblar.

Three.js trae esa implementación en los addons y está bien hecha. Las tres piezas son LineGeometry, LineMaterial y Line2, más las variantes LineSegmentsGeometry y LineSegments2 para segmentos sueltos.

import * as THREE from 'three';
import { Line2 } from 'three/addons/lines/Line2.js';
import { LineGeometry } from 'three/addons/lines/LineGeometry.js';
import { LineMaterial } from 'three/addons/lines/LineMaterial.js';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  50, window.innerWidth / window.innerHeight, 0.1, 200
);
camera.position.set(0, 3, 12);
camera.lookAt(0, 0, 0);

// Una helice como polilinea.
const posiciones = [];
const colores = [];
const color = new THREE.Color();

for (let i = 0; i <= 600; i++) {
  const t = i / 600;
  const a = t * Math.PI * 8;
  posiciones.push(Math.cos(a) * 3, t * 6 - 3, Math.sin(a) * 3);
  color.setHSL(t, 0.65, 0.6, THREE.SRGBColorSpace);
  colores.push(color.r, color.g, color.b);
}

const geometry = new LineGeometry();
geometry.setPositions(posiciones);
geometry.setColors(colores);

const material = new LineMaterial({
  linewidth: 6,          // pixeles, salvo que worldUnits sea true
  worldUnits: false,
  vertexColors: true,
  dashed: false,
  alphaToCoverage: true  // suaviza el borde con el MSAA del contexto
});

const linea = new Line2(geometry, material);
scene.add(linea);

window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

renderer.setAnimationLoop(() => renderer.render(scene, camera));

Hay tres decisiones que conviene entender en ese bloque.

worldUnits cambia el significado de linewidth. Con false, el grosor se mide en píxeles y la línea se ve igual de gruesa esté cerca o lejos: es lo que quieres para diagramas, contornos y ayudas visuales. Con true, el grosor está en unidades de la escena y la línea adelgaza con la distancia como cualquier objeto: es lo que quieres para un cable, una cuerda o una traza que pertenece al mundo.

alphaToCoverage aprovecha el multisampling del contexto para suavizar los bordes del quad. Solo funciona si creaste el renderer con antialias: true, porque sin MSAA no hay muestras que cubrir. Es la forma más barata de que una línea gruesa no se vea escalonada.

Y no hace falta que le pases la resolución de la pantalla. En revisiones antiguas era obligatorio llamar a material.resolution.set(width, height) en cada resize, y olvidarlo era la causa número uno de líneas del grosor equivocado. En r184, LineSegments2 actualiza el uniform de resolución desde el viewport del renderer en su propio onBeforeRender. Si encuentras un tutorial que insiste en ese set, es de otra época.

Line2 es un Mesh, con todo lo que eso implica

Line2 extiende Mesh, no Line. Su geometría es un único quad de dos triángulos instanciado una vez por segmento, con los extremos de cada segmento en dos atributos de instancia, instanceStart e instanceEnd, empaquetados en un InstancedInterleavedBuffer. De ahí salen tres consecuencias que sorprenden a todo el mundo. Primera: es una sola llamada de dibujo para toda la polilínea, así que el coste de CPU es el mismo que el de una Line normal. Segunda: como es un Mesh, participa en el pipeline de mallas, incluidas sombras y renderOrder, cosa que una Line hace peor. Tercera: el raycast tiene dos implementaciones distintas según worldUnits, y en el modo de píxeles necesita conocer la cámara, por lo que raycaster.camera tiene que estar puesto o verás un error por consola; el umbral extra se configura en raycaster.params.Line2.threshold. Y si estás en WebGPURenderer, el módulo no es el mismo: hay una versión paralela en three/addons/lines/webgpu/.

Las otras dos salidas

La línea gruesa por geometría no siempre es la respuesta correcta. Hay dos alternativas con perfiles distintos.

Un tubo con TubeGeometry construye una malla cilíndrica siguiendo una curva. Es la única opción si necesitas que la línea reciba luz, proyecte sombra o tenga volumen real desde cualquier ángulo. Cuesta muchísimo más: un tubo de 600 puntos con 8 segmentos radiales son casi diez mil triángulos frente a los 600 quads de Line2, y regenerarlo cuando la curva cambia implica reconstruir la geometría entera.

Un sprite estirado o una tira de quads orientados a cámara sirve para trazas y estelas donde el ancho varía a lo largo del recorrido y la línea tiene textura. Es lo que usan los sistemas de estelas de partículas, y es esencialmente Line2 con un atributo de anchura por vértice y una UV que avanza a lo largo de la traza.

La regla de decisión es corta. Si la línea es información sobre la escena, Line2 en píxeles. Si la línea es un objeto de la escena que debe iluminarse, TubeGeometry. Si la línea tiene textura o anchura variable, una tira propia. Y si son cientos de miles de segmentos que solo tienen que verse, LineSegments con un píxel de ancho sigue siendo lo más rápido que existe.

⚔️ Reto práctico

Dibuja la misma hélice con THREE.Line y con Line2 en la misma escena, separadas en x, y compara la aparición de los dos artefactos: el escalonado de la línea de un píxel y el aliasing del quad sin alphaToCoverage. Después activa worldUnits en el LineMaterial y encuentra el valor de linewidth que iguala visualmente al de píxeles a la distancia inicial de la cámara.