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

  1. Candidates live on a grid with the cell of the smallest r, as if the whole map were its densest place.
  2. Each candidate survives with probability (rmin / r)². Where r is twice as large, a quarter remain: the density follows 1/r².
  3. Two candidates clash if they are closer than a pair distance made from their two r. Here it is the larger of the two.
  4. 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.

Casenearest neighbour / local r (min · mean)n·r² (small r → large)pairs closer than r: seams · borderschunked = whole
Forest, pair = larger1.00 · 1.160.49 → 0.460 · –10/10
Forest, pair = mean0.97 · 1.150.50 → 0.470 · –10/10
Forest, pair = smaller0.94 · 1.140.50 → 0.470 · –10/10
Regions, each on its ownforest 0.27forest 0.50 → 0.490 · 5310/10
Regions, shared bordersforest and orchard 1.00; meadow 0.10orchard 0.29–0.64 (in steps); meadow 0.41–0.470 · 010/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

  1. 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.
  2. Cada candidato sobrevive con probabilidad (rmín / r)². Donde r es el doble quedan la cuarta parte: la densidad sigue a 1/r².
  3. 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.
  4. 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.

Casovecino más cercano / r local (mín · media)n·r² (r pequeño → grande)pares a menos de r: costuras · fronteraschunks = entero
Bosque, par = mayor1,00 · 1,160,49 → 0,460 · –10/10
Bosque, par = media0,97 · 1,150,50 → 0,470 · –10/10
Bosque, par = menor0,94 · 1,140,50 → 0,470 · –10/10
Regiones, cada una solabosque 0,27bosque 0,50 → 0,490 · 5310/10
Regiones, fronteras compartidasbosque y huerto 1,00; pradera 0,10huerto 0,29–0,64 (por escalones); pradera 0,41–0,470 · 010/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

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.