Sea of Points
A map that never ends, made only of points. One noise field decides how close the points may be; another splits the world into forest, orchard and meadow, each with its own method. Press ▶ to travel, drag sideways, and turn on Chunks and conflicts to look for seams.✦ IA How it works.
Un mapa que no termina, hecho solo de puntos. Un campo de ruido decide cuánto se pueden acercar los puntos; otro divide el mundo en bosque, huerto y pradera, cada uno con su método. Pulsa ▶ para viajar, arrastra de lado y activa Chunks y conflictos para buscar costuras.✦ IA Cómo funciona.
The idea in plain words
In part 6 every point kept the same distance, r. A real map is not like that: there are thick woods and clearings. Here r changes from place to place, following a noise field: where r is small the points crowd together, where it is large they spread out.
A second field splits the world into regions, and each one places its points its own way: the forest with hashed priority, the orchard in rows, the meadow by chance. Turn on Fields to see both maps under the glyphs. ✦ IA
Variable r, step by step ✦ IA
- Candidates live on a grid with the cell of the smallest r, as if the whole map were its densest place.
- Each candidate survives with probability (rmin / r)². Where r is twice as large, a quarter remain: the density follows 1/r².
- Two candidates clash if they are closer than a pair distance made from their two r. Here it is the larger of the two.
- The priority rounds decide who stays, as in part 6.
Borders between regions ✦ IA
If each region decides on its own, nothing stops a forest tree and an orchard tree from touching at the border. The fix is the one used at chunk seams: pairs from different regions compete in the same priority rounds. Turn off Shared borders and turn on Chunks and conflicts to see the pairs that appear.
What was measured ✦ IA
With this page's code (tools/scenes/sea-of-points-bench.mjs): 10 seeds, a world of 3 × 3 chunks, r from 0.025 to 0.1 (contrast × 4) and 3 rounds.
| Case | nearest neighbour / local r (min · mean) | n·r² (small r → large) | pairs closer than r: seams · borders | chunked = whole |
|---|---|---|---|---|
| Forest, pair = larger | 1.00 · 1.16 | 0.49 → 0.46 | 0 · – | 10/10 |
| Forest, pair = mean | 0.97 · 1.15 | 0.50 → 0.47 | 0 · – | 10/10 |
| Forest, pair = smaller | 0.94 · 1.14 | 0.50 → 0.47 | 0 · – | 10/10 |
| Regions, each on its own | forest 0.27 | forest 0.50 → 0.49 | 0 · 53 | 10/10 |
| Regions, shared borders | forest and orchard 1.00; meadow 0.10 | orchard 0.29–0.64 (in steps); meadow 0.41–0.47 | 0 · 0 | 10/10 |
- With the larger of the two r, no point is closer to another than its own r; with the mean or the smaller, points in the transitions drop to 0.97 and 0.94.
- The density follows 1/r² within a few hundredths: n·r² falls from 0.49 to 0.46 from dense to sparse places (with a fixed r, part 6 gave 0.51).
- A chunk takes 14 to 30 ms on average in Node on a desktop PC. On a phone it is not measured: the legend shows the time of the last chunk.
What it does not do
No relief and no water: both fields are plain simplex noise. Region borders are sharp cuts of a field, not ecotones. The orchard changes its spacing in steps (one grid per octave of r), so its density swings around the forest's. None of this is in Islands yet. ✦ IA
How it works ✦ IA
This part walks through the scene's code. It covers:
- Two noise fields: the spacing r and the region.
- Candidates with a variable r.
- The pair distance, and why the larger of the two r.
- The terraced orchard.
- Shared borders, the halo and chunks.
- A cache that spreads the work across frames.
Plain JavaScript and Canvas 2D, no libraries. The core (src/scripts/sea-of-points/core.js) never touches the DOM and returns flat lists, to port to C#.
1. Two fields
Both are the simplex noise of part 4, with different seeds. The spacing is blended on a log scale: equal steps of the noise multiply r by the same factor. The region field is cut into three bands.
// The spacing r at (x, y): a log blend from rMin to rMax, so equal steps of the noise
// multiply r by equal factors (and the density, 1/r², too).
export function radiusAt(x, y, p) {
const t = smooth(field01(p.seed + 101, x, y, p.densityScale));
return p.rMin * Math.pow(p.rMax / p.rMin, t);
}
export function regionAt(x, y, p) {
if (!p.regions) return FOREST;
const v = field01(p.seed + 202, x, y, p.regionScale);
return v < 0.38 ? MEADOW : v < 0.62 ? FOREST : ORCHARD;
} 2. Candidates with variable r
The grid has the cell of the smallest r, so dense places have candidates to spare. Where r grows they are dropped by a hashed roll: (rmin / r)². The meadow keeps even fewer, because nobody competes there and it would keep them all.
const s = p.seed, cell = p.rMin / Math.SQRT2;
for (let gj = …) for (let gi = …) {
const x = (gi + h01(s, gi, gj, 9)) * cell, y = (gj + h01(s, gi, gj, 10)) * cell;
const reg = regionAt(x, y, p);
if (reg === ORCHARD) continue;
const r = radiusAt(x, y, p);
const keep = (p.rMin / r) ** 2 * (reg === MEADOW ? DENSITY / 2 : 1);
if (h01(s, gi, gj, 30) >= keep) continue;
out.push(x, y, r, reg, h32(s, gi, gj, 11)); // x, y, r, region, priority
} 3. The pair distance
With a fixed r, two points clash closer than r. With two different r one has to choose. With the larger, the point in the sparse place lets nobody into its circle; with the smaller, it does. Try the three rules with the bench tool: only the larger gives 1.00.
export const PAIR = {
max: (ra, rb) => (ra > rb ? ra : rb),
mean: (ra, rb) => (ra + rb) / 2,
min: (ra, rb) => (ra < rb ? ra : rb),
};
…
const d = pairRadius(r, c[5 * o + 2]);
if ((c[5 * o] - x) ** 2 + (c[5 * o + 1] - y) ** 2 < d * d) adj.push(o); 4. The terraced orchard
A jittered grid cannot change its step and stay a grid. So there is one grid per octave of r, with cell 2k·rmin/√0.47, and each point stays only where the local r falls in its octave. Rows stay rows; the step changes in stairs.
const levels = Math.max(1, Math.round(Math.log2(p.rMax / p.rMin)) + 1);
for (let lv = 0; lv < levels; lv++) {
const g = (p.rMin * 2 ** lv) / Math.sqrt(DENSITY);
for (let gj = …) for (let gi = …) {
const x = (gi + 0.5 + 0.15 * (h01(s, gi, gj, 40 + 4 * lv) - 0.5)) * g;
const y = (gj + 0.5 + 0.15 * (h01(s, gi, gj, 41 + 4 * lv) - 0.5)) * g;
if (regionAt(x, y, p) !== ORCHARD) continue;
const r = radiusAt(x, y, p);
if (Math.min(levels - 1, Math.max(0, Math.round(Math.log2(r / p.rMin)))) !== lv) continue;
out.push(x, y, r, ORCHARD, h32(s, gi, gj, 43 + 4 * lv));
}
} 5. Shared borders and the halo
Inside the forest and the orchard candidates compete; inside the meadow they do not. Across regions they compete only with Shared borders on.
function competes(a, b, p) {
if (a !== b) return p.shared;
return a !== MEADOW;
} As in part 6, each round lets a candidate's verdict depend on others within 2·rmax. A halo of 2·rmax·rounds around the chunk is enough for a chunk computed alone to equal the whole world: measured, 10 out of 10.
export function generate(p, b, info = null) {
const halo = 2 * p.rMax * p.rounds;
const c = []; // 5 numbers per candidate: x, y, r, region, priority
candidates(p, b.x0 - halo, b.y0 - halo, b.x1 + halo, b.y1 + halo, c);
… // neighbour lists, then Luby's rounds as in part 6
} 6. The cache
Each chunk is computed once and kept. Every frame builds the missing ones, visible first and one column ahead, until 12 ms are spent; the rest waits for the next frame.
// Build missing chunks, the visible ones first and one column ahead, within a time
// budget per frame, so a slow phone spreads the work instead of freezing.
function build(budget = 12) {
const [c0, c1] = visibleCols(), t0 = performance.now();
for (let ci = c0; ci <= c1 + 1; ci++) {
for (let cj = 0; cj < ROWS; cj++) {
const k = ci + "," + cj;
if (chunks.has(k)) continue;
chunks.set(k, chunkPoints(ci, cj, P)); // … and its time, for the legend
if (performance.now() - t0 > budget) return true;
}
}
…
} Going further
Port generate to C# in Islands, with a golden test: chunked = whole. Blend regions at their edges (ecotones) instead of cutting them. And use these points as Voronoi seeds, part 7. ✦ IA
La idea en palabras sencillas
En la parte 6 todos los puntos guardaban la misma distancia, r. Un mapa de verdad no es así: hay bosques espesos y claros. Aquí r cambia de un lugar a otro, siguiendo un campo de ruido: donde r es pequeño los puntos se apiñan, donde es grande se separan.
Un segundo campo reparte el mundo en regiones, y cada una coloca sus puntos a su manera: el bosque con la prioridad por hash, el huerto en hileras y la pradera al azar. Activa Campos para ver los dos mapas debajo de los glifos. ✦ IA
r variable, paso a paso ✦ IA
- Los candidatos viven en una rejilla con la celda del r más pequeño, como si todo el mapa fuera el lugar más denso.
- Cada candidato sobrevive con probabilidad (rmín / r)². Donde r es el doble quedan la cuarta parte: la densidad sigue a 1/r².
- Dos candidatos chocan si están más cerca que una distancia de par hecha con sus dos r. Aquí se usa el mayor de los dos.
- Las rondas de prioridad deciden quién se queda, igual que en la parte 6.
Fronteras entre regiones ✦ IA
Si cada región decide sola, nada impide que un árbol del bosque y uno del huerto queden pegados en la frontera. La solución es la misma que en las costuras de los chunks: los pares de regiones distintas compiten en la misma ronda de prioridad. Desactiva Fronteras compartidas y activa Chunks y conflictos para ver los pares que aparecen.
Lo medido ✦ IA
Con el código de esta página (tools/scenes/sea-of-points-bench.mjs): 10 semillas, un mundo de 3 × 3 chunks, r de 0,025 a 0,1 (contraste × 4) y 3 rondas.
| Caso | vecino más cercano / r local (mín · media) | n·r² (r pequeño → grande) | pares a menos de r: costuras · fronteras | chunks = entero |
|---|---|---|---|---|
| Bosque, par = mayor | 1,00 · 1,16 | 0,49 → 0,46 | 0 · – | 10/10 |
| Bosque, par = media | 0,97 · 1,15 | 0,50 → 0,47 | 0 · – | 10/10 |
| Bosque, par = menor | 0,94 · 1,14 | 0,50 → 0,47 | 0 · – | 10/10 |
| Regiones, cada una sola | bosque 0,27 | bosque 0,50 → 0,49 | 0 · 53 | 10/10 |
| Regiones, fronteras compartidas | bosque y huerto 1,00; pradera 0,10 | huerto 0,29–0,64 (por escalones); pradera 0,41–0,47 | 0 · 0 | 10/10 |
- Con el mayor de los dos r, ningún punto queda más cerca de otro que su propio r; con la media o el menor, los puntos de las transiciones bajan a 0,97 y 0,94.
- La densidad sigue a 1/r² con unas pocas centésimas de error: n·r² baja de 0,49 a 0,46 de los lugares densos a los ralos (con r fijo, la parte 6 daba 0,51).
- Un chunk cuesta de 14 a 30 ms de media en Node en un PC de escritorio. En el móvil no está medido: la leyenda muestra el tiempo del último chunk.
Lo que no hace
No hay relieve ni agua: los dos campos son ruido simplex sin más. Las fronteras entre regiones son cortes netos de un campo, no ecotonos. El huerto cambia de paso por escalones (una rejilla por octava de r), así que su densidad sube y baja alrededor de la del bosque. Nada de esto está en Islands todavía. ✦ IA
Cómo funciona ✦ IA
Esta parte recorre el código de la escena. Cubre:
- Dos campos de ruido: la separación r y la región.
- Candidatos con r variable.
- La distancia de par y por qué el mayor de los dos r.
- El huerto en terrazas.
- Fronteras compartidas, el halo y los chunks.
- Una caché que reparte el trabajo entre cuadros.
JavaScript sin más y Canvas 2D, sin librerías. El núcleo (src/scripts/sea-of-points/core.js) no toca el DOM y devuelve listas planas, para pasarlo a C#. Los comentarios del código están en inglés.
1. Dos campos
Los dos son el ruido simplex de la parte 4, con semillas distintas. El de la separación se mezcla en escala logarítmica: pasos iguales del ruido multiplican r por el mismo factor. El de las regiones se corta en tres franjas.
// The spacing r at (x, y): a log blend from rMin to rMax, so equal steps of the noise
// multiply r by equal factors (and the density, 1/r², too).
export function radiusAt(x, y, p) {
const t = smooth(field01(p.seed + 101, x, y, p.densityScale));
return p.rMin * Math.pow(p.rMax / p.rMin, t);
}
export function regionAt(x, y, p) {
if (!p.regions) return FOREST;
const v = field01(p.seed + 202, x, y, p.regionScale);
return v < 0.38 ? MEADOW : v < 0.62 ? FOREST : ORCHARD;
} 2. Candidatos con r variable
La rejilla tiene la celda del r más pequeño, así que en los lugares densos hay candidatos de sobra. Donde r crece se descartan con una tirada del hash: (rmín / r)². La pradera guarda menos todavía, porque nadie compite y se quedaría con todos.
const s = p.seed, cell = p.rMin / Math.SQRT2;
for (let gj = …) for (let gi = …) {
const x = (gi + h01(s, gi, gj, 9)) * cell, y = (gj + h01(s, gi, gj, 10)) * cell;
const reg = regionAt(x, y, p);
if (reg === ORCHARD) continue;
const r = radiusAt(x, y, p);
const keep = (p.rMin / r) ** 2 * (reg === MEADOW ? DENSITY / 2 : 1);
if (h01(s, gi, gj, 30) >= keep) continue;
out.push(x, y, r, reg, h32(s, gi, gj, 11)); // x, y, r, region, priority
} 3. La distancia de par
Con r fijo, dos puntos chocan a menos de r. Con dos r distintos hay que elegir. Con el mayor, el punto del lugar ralo no deja que nadie entre en su círculo; con el menor, sí. Prueba las tres reglas con la herramienta de medida: solo la del mayor da 1,00.
export const PAIR = {
max: (ra, rb) => (ra > rb ? ra : rb),
mean: (ra, rb) => (ra + rb) / 2,
min: (ra, rb) => (ra < rb ? ra : rb),
};
…
const d = pairRadius(r, c[5 * o + 2]);
if ((c[5 * o] - x) ** 2 + (c[5 * o + 1] - y) ** 2 < d * d) adj.push(o); 4. El huerto en terrazas
Una rejilla con jitter no puede cambiar de paso sin dejar de ser rejilla. Por eso hay una rejilla por octava de r, con celda 2k·rmín/√0,47, y cada punto se queda solo donde el r local cae en su octava. Las hileras siguen siendo hileras; el paso cambia por escalones.
const levels = Math.max(1, Math.round(Math.log2(p.rMax / p.rMin)) + 1);
for (let lv = 0; lv < levels; lv++) {
const g = (p.rMin * 2 ** lv) / Math.sqrt(DENSITY);
for (let gj = …) for (let gi = …) {
const x = (gi + 0.5 + 0.15 * (h01(s, gi, gj, 40 + 4 * lv) - 0.5)) * g;
const y = (gj + 0.5 + 0.15 * (h01(s, gi, gj, 41 + 4 * lv) - 0.5)) * g;
if (regionAt(x, y, p) !== ORCHARD) continue;
const r = radiusAt(x, y, p);
if (Math.min(levels - 1, Math.max(0, Math.round(Math.log2(r / p.rMin)))) !== lv) continue;
out.push(x, y, r, ORCHARD, h32(s, gi, gj, 43 + 4 * lv));
}
} 5. Fronteras compartidas y halo
Dentro del bosque y del huerto los candidatos compiten; dentro de la pradera, no. Entre regiones, compiten solo con Fronteras compartidas activado.
function competes(a, b, p) {
if (a !== b) return p.shared;
return a !== MEADOW;
} Como en la parte 6, en cada ronda el veredicto de un candidato depende de otros a menos de 2·rmáx. Un halo de 2·rmáx·rondas alrededor del chunk basta para que un chunk calculado solo dé lo mismo que el mundo entero: medido, 10 de 10.
export function generate(p, b, info = null) {
const halo = 2 * p.rMax * p.rounds;
const c = []; // 5 numbers per candidate: x, y, r, region, priority
candidates(p, b.x0 - halo, b.y0 - halo, b.x1 + halo, b.y1 + halo, c);
… // neighbour lists, then Luby's rounds as in part 6
} 6. La caché
Cada chunk se calcula una vez y se guarda. En cada cuadro se construyen los que faltan, los visibles primero y una columna por delante, hasta gastar 12 ms; el resto espera al cuadro siguiente.
// Build missing chunks, the visible ones first and one column ahead, within a time
// budget per frame, so a slow phone spreads the work instead of freezing.
function build(budget = 12) {
const [c0, c1] = visibleCols(), t0 = performance.now();
for (let ci = c0; ci <= c1 + 1; ci++) {
for (let cj = 0; cj < ROWS; cj++) {
const k = ci + "," + cj;
if (chunks.has(k)) continue;
chunks.set(k, chunkPoints(ci, cj, P)); // … and its time, for the legend
if (performance.now() - t0 > budget) return true;
}
}
…
} Para ir más allá
Pasar generate a C# en Islands, con una prueba de oro: chunks = entero. Mezclar las regiones en sus bordes (ecotonos) en vez de cortarlas. Y usar estos puntos como semillas de Voronoi, la parte 7. ✦ IA
SourcesFuentes
- Islands Lab part 6, "Spreading Points on a Plane": the methods, the hashed priority in rounds and how they were measured. claudiobustamante.com/labs/islands/points-2d
- B. Matérn, Spatial Variation, 2nd ed., Lecture Notes in Statistics 36, Springer (1986). First published 1960; the hard-core processes behind "hashed priority". doi.org/10.1007/978-1-4615-7892-5
- M. Luby, "A simple parallel algorithm for the maximal independent set problem", SIAM J. Comput. 15(4) (1986) 1036–1053. doi.org/10.1137/0215074
- S. Gustavson, "Simplex noise demystified" (2005). The noise behind both fields. weber.itn.liu.se/~stegu/simplexnoise/simplexnoise.pdf
Part of the Islands Lab series: it puts part 6 and the simplex noise of part 4 to work. Texts marked ✦ IA were written with AI (on a heading: the whole section; ✓: reviewed by Claudio); the code, the measures and the choices were checked on this page.Parte de la serie Islands Lab: pone a trabajar la parte 6 y el ruido simplex de la parte 4. Los textos marcados con ✦ IA están escritos con IA (en un título: toda la sección; ✓: revisados por Claudio); el código, las medidas y las decisiones se comprobaron en esta página.