wandres.dev
WGSL IV · Funciones y control de flujo

Punteros: para qué sirven y las cuatro cosas que no puedes hacer con ellos

El tipo ptr y sus tres parámetros, qué expresiones admiten el operador de dirección, las restricciones de los parámetros de función, y las dos extensiones de lenguaje que las relajan.

⏱ 17 min

WGSL tiene punteros, y quien viene de C se lleva una sorpresa doble: por un lado existen y funcionan; por otro, la lista de cosas que no se pueden hacer con ellos es tan larga que el conjunto resultante apenas se parece a un puntero. La razón es que un puntero de WGSL no es una dirección de memoria genérica: es una referencia tipada a un espacio de direcciones concreto, y esa diferencia lo condiciona todo.

🎯 Al terminar esta lección sabrás
  • Escribir el tipo ptr completo con su espacio de direcciones y su modo de acceso.
  • Formar punteros con el operador de dirección solo donde el lenguaje lo permite.
  • Enunciar las restricciones de los parámetros de función de tipo puntero.
  • Detectar cuándo hacen falta las extensiones de punteros y comprobar su disponibilidad.

El tipo ptr

Un puntero lleva tres parámetros de tipo, aunque el tercero casi siempre se omite:

ptr<function, vec3f>                   // espacio y tipo apuntado
ptr<workgroup, array<f32, 64>>
ptr<storage, Particula, read_write>    // el modo solo se escribe en storage

El espacio de direcciones forma parte del tipo. Un ptr<function, f32> y un ptr<workgroup, f32> son tipos incompatibles, y no hay conversión entre ellos. Esa es la diferencia esencial con C, donde un float* es un float* venga de donde venga.

Las dos operaciones son el ampersand para formar el puntero y el asterisco para acceder a lo apuntado:

fn duplicar(p : ptr<function, f32>) {
  *p = *p * 2.0;
}

fn ejemplo() {
  var x = 3.0;
  duplicar(&x);       // x vale ahora 6.0
}

Fíjate en que el paréntesis importa al acceder a miembros: (*p).campo, no *p.campo, porque el punto se aplica antes que el asterisco. Es exactamente la trampa de C y de ahí viene la extensión que la elimina, que veremos al final.

Formar un puntero: lo que se puede

El operador & se aplica a vistas de memoria, no a valores. Es decir: a un var, o a un miembro o un elemento de un var.

var v : vec3f;
var s : Material;
var a : array<f32, 8>;

let p1 = &v;              // legal: la variable entera
let p2 = &s.rugosidad;    // legal: un miembro
let p3 = &a[3];           // legal: un elemento

let valor = 1.0;
// let p4 = &valor;       // ERROR: un let no tiene direccion
// let p5 = &v.x;         // ERROR: los componentes de un vector no la tienen

Los dos errores de arriba merecen explicación. Un let no tiene dirección porque no es memoria: es un valor que el compilador puede tener en un registro o haber sustituido por una constante. Los componentes de un vector no la tienen porque un vector puede vivir en un registro empaquetado donde sus componentes no son direccionables por separado; permitirlo obligaría a que todo vector del que se tomara una dirección de componente bajara a memoria.

Y las tres cosas que no se pueden hacer con un puntero una vez formado:

No se puede guardar en una struct ni en un array. No hay listas enlazadas, no hay árboles de punteros, no hay grafos con referencias. Lo que se guarda son índices enteros dentro de un array, que es como se representan todas las estructuras de datos en GPU.

No se puede devolver desde una función. Ninguna fn puede tener ptr como tipo de retorno.

No se puede convertir a entero ni al revés. No hay aritmética de punteros, no hay reinterpret_cast, no hay forma de fabricar una dirección.

Los parámetros de función

Aquí está la restricción que produce los errores más desconcertantes. En el lenguaje base, un parámetro de tipo puntero solo puede apuntar a los espacios function, private y workgroup, y el argumento tiene que ser la dirección de una variable completa, no de un miembro o un elemento.

var<workgroup> tesela : array<f32, 64>;

// Legal en el lenguaje base: espacio workgroup, variable completa.
fn sumarTesela(p : ptr<workgroup, array<f32, 64>>) -> f32 {
  var t = 0.0;
  for (var i = 0u; i < 64u; i = i + 1u) { t = t + (*p)[i]; }
  return t;
}

Y lo que el lenguaje base no permite:

@group(0) @binding(0) var<storage, read> datos : array<f32>;

// Necesita unrestricted_pointer_parameters: el espacio es storage.
fn procesar(p : ptr<storage, array<f32>, read>) -> f32 { /* ... */ }

// Necesita unrestricted_pointer_parameters: apunta a un subobjeto.
fn tocar(p : ptr<function, f32>) { *p = 1.0; }
fn llamador() {
  var m : Material;
  tocar(&m.rugosidad);       // el argumento no es una variable completa
}

La razón de la restricción original es de portabilidad: no todos los backends admitían punteros a memoria de recurso, y permitirlos habría obligado a las implementaciones a hacer copias. La extensión existe porque ya casi todos lo admiten.

Las dos extensiones

Dos extensiones de lenguaje relajan estas reglas, y las dos se comprueban en tiempo de ejecución antes de compilar el shader:

const f = navigator.gpu.wgslLanguageFeatures;
const punterosLibres = f.has('unrestricted_pointer_parameters');
const accesoCompuesto = f.has('pointer_composite_access');

unrestricted_pointer_parameters permite las dos cosas de arriba: parámetros que apuntan a uniform y a storage, y argumentos que son punteros a un subobjeto.

pointer_composite_access permite acceder a miembros y elementos a través del puntero sin desreferenciar a mano:

requires pointer_composite_access;

fn ajustar(p : ptr<function, Material>) {
  p.rugosidad = 0.5;        // en lugar de (*p).rugosidad
}

fn primero(p : ptr<function, array<f32, 8>>) -> f32 {
  return p[0];              // en lugar de (*p)[0]
}

Es puramente sintáctica y no cambia nada del código generado, pero elimina el paréntesis que todo el mundo olvida.

⚠️
Declara requires aunque el navegador ya la soporte

Escribir requires unrestricted_pointer_parameters; en un navegador que ya la implementa no cambia nada del comportamiento. Escribirla en uno que no la implementa produce un error claro que menciona la extensión, en lugar de un error de sintaxis en la línea del parámetro. Es documentación ejecutable y cuesta una línea.

Que el espacio de direcciones esté en el tipo hace imposible la función auxiliar genérica, y eso condiciona cómo se estructura un shader

Esta es la consecuencia que más duele en la práctica y de la que nadie avisa.

Escribes una función de reducción que suma un array. La quieres usar sobre un array en memoria de grupo en un kernel, y sobre un array en un storage buffer en otro. En cualquier lenguaje con genéricos sería una función. Aquí son dos, con el mismo cuerpo y distinto tipo de parámetro, porque ptr<workgroup, ...> y ptr<storage, ...> son tipos incompatibles y WGSL no tiene genéricos de usuario ni sobrecarga.

Las salidas que existen, ninguna perfecta. Duplicar la función y aceptar la copia, que es lo que hace la mayoría del código real y funciona hasta que las dos versiones se desincronizan. Generar el texto desde JavaScript con una plantilla que emita las dos variantes desde una sola fuente, que es correcto y es la razón de que casi todos los motores serios acaben con un pequeño generador de WGSL. O reestructurar para que la función no reciba el array: en vez de pasar el puntero, la función recibe los valores ya cargados y devuelve el resultado, y quien la llama se encarga de leer de donde sea.

La tercera opción es la que mejor envejece y la que menos se usa. Una reducción escrita como «dame dos valores y te doy la combinación» no sabe nada de espacios de direcciones, se prueba en aislamiento, y sirve igual en workgroup, en storage y en registros. La función que además sabe leer memoria está mezclando dos responsabilidades, y es justo la mezcla que el sistema de tipos de WGSL te impide reutilizar.

Dicho de otra forma: la restricción del lenguaje está empujando hacia un diseño mejor, aunque la primera vez que te la encuentras parezca lo contrario. Cuando te veas a punto de escribir la misma función dos veces por el espacio de direcciones, para y pregunta si esa función tenía que estar tocando memoria.