diff --git a/orx-noise/build.gradle.kts b/orx-noise/build.gradle.kts index 0e50d5c6..96034c95 100644 --- a/orx-noise/build.gradle.kts +++ b/orx-noise/build.gradle.kts @@ -23,6 +23,7 @@ kotlin { kotlin.srcDir("src/demo") dependencies { implementation(project(":orx-camera")) + implementation(project(":orx-hash-grid")) implementation("org.openrndr:openrndr-application:$openrndrVersion") implementation("org.openrndr:openrndr-extensions:$openrndrVersion") runtimeOnly("org.openrndr:openrndr-gl3:$openrndrVersion") @@ -56,6 +57,8 @@ kotlin { implementation("org.openrndr:openrndr-math:$openrndrVersion") implementation("org.openrndr:openrndr-shape:$openrndrVersion") implementation("org.openrndr:openrndr-draw:$openrndrVersion") + implementation(project(":orx-hash-grid")) + } } @Suppress("UNUSED_VARIABLE") diff --git a/orx-noise/src/commonMain/kotlin/PoissonDisk.kt b/orx-noise/src/commonMain/kotlin/PoissonDisk.kt index f09b9639..0bfdfd7f 100644 --- a/orx-noise/src/commonMain/kotlin/PoissonDisk.kt +++ b/orx-noise/src/commonMain/kotlin/PoissonDisk.kt @@ -1,11 +1,9 @@ package org.openrndr.extra.noise +import org.openrndr.extra.hashgrid.HashGrid import org.openrndr.math.Polar import org.openrndr.math.Vector2 -import org.openrndr.math.clamp import org.openrndr.shape.Rectangle -import kotlin.math.ceil -import kotlin.math.sqrt import kotlin.random.Random /* @@ -21,8 +19,7 @@ internal const val epsilon = 0.0000001 * By default the points are generated along the circumference of r + epsilon to the point * They can also be generated on a ring like in the original algorithm from Robert Bridson * - * @param width the width of the area - * @param height the height of the area + * @param bounds the rectangular bounds of the area to generate points in * @param r the minimum distance between each point * @param tries number of candidates per point * @param randomOnRing generate random points on a ring with an annulus from r to 2r @@ -33,101 +30,65 @@ internal const val epsilon = 0.0000001 * @return a list of points */ fun poissonDiskSampling( - width: Double, - height: Double, - r: Double, - tries: Int = 30, - randomOnRing: Boolean = false, - random: Random = Random.Default, - initialPoints: List = listOf(Vector2(width/2.0, height/2.0)), - boundsMapper: ((w: Double, h: Double, v: Vector2) -> Boolean)? = null, + bounds: Rectangle, + radius: Double, + tries: Int = 30, + randomOnRing: Boolean = true, + random: Random = Random.Default, + initialPoints: List = listOf(bounds.center), + obstacleHashGrids: List = emptyList(), + boundsMapper: ((v: Vector2) -> Boolean)? = null, ): List { val disk = mutableListOf() - val queue = mutableListOf() + val queue = mutableSetOf>() + val hashGrid = HashGrid(radius) - val r2 = r * r - val radius = r + epsilon - - val cellSize = r / sqrt(2.0) - val rows = ceil(height / cellSize).toInt() - val cols = ceil(width / cellSize).toInt() - - val grid = Array(rows * cols) { -1 } - - fun addPoint(v: Vector2) { - val x = (v.x / cellSize).fastFloor() - val y = (v.y / cellSize).fastFloor() - val index = x + y * cols - - if (x >= 0 && y >= 0 && x < cols && y < rows) { - disk.add(v) - grid[index] = disk.lastIndex - queue.add(disk.lastIndex) - } + fun addPoint(v: Vector2, radius: Double) { + hashGrid.insert(v) + disk.add(v) + queue.add(Pair(v, radius)) } for (initialPoint in initialPoints) { - addPoint(initialPoint) + addPoint(initialPoint, radius) } - val boundsRect = Rectangle(0.0, 0.0, width, height) + for (ohg in obstacleHashGrids) { + for (point in ohg.points()) { + queue.add(Pair(point, ohg.radius)) + } + } while (queue.isNotEmpty()) { - val activeIndex = queue.random(random) - val active = disk[activeIndex] - + val queueItem = queue.random(random) + val (active, activeRadius) = queueItem var candidateAccepted = false - candidateSearch@ for (l in 0 until tries) { val c = if (randomOnRing) { - active + Vector2.uniformRing(r, 2 * r, random) + active + Vector2.uniformRing(activeRadius, 2 * activeRadius- epsilon, random) } else { - active + Polar(random.nextDouble(0.0, 360.0), radius).cartesian + active + Polar(random.nextDouble(0.0, 360.0), activeRadius).cartesian } - if (!boundsRect.contains(c)) continue@candidateSearch + if (!bounds.contains(c)) continue@candidateSearch - val x = (c.x / cellSize).fastFloor() - val y = (c.y / cellSize).fastFloor() - - // EJ: early bail-out; - // if grid[y,x] is populated we know that its inhabitant is within the minimum point distance - if (grid[x + y * cols] != -1) { + if (!hashGrid.isFree(c) || obstacleHashGrids.any { !it.isFree(c) }) continue@candidateSearch - } - - // Check closest neighbours in a 5x5 grid - for (iy in (-2..2)) { - for (ix in (-2..2)) { - val nx = clamp(x + ix, 0, cols - 1) - val ny = clamp(y + iy, 0, rows - 1) - - val neighborIdx = grid[nx + ny * cols] - - // -1 means the grid has no sample at that point - if (neighborIdx == -1) continue - - val neighbor = disk[neighborIdx] - - // if the candidate is within one of the neighbours radius, try another candidate - if ((neighbor - c).squaredLength <= r2) continue@candidateSearch - } - } // check if the candidate point is within bounds // EJ: This is somewhat counter-intuitively moved to the last stage in the process; // It turns out that the above neighbour search is much more affordable than the bounds check in the // case of complex bounds (such as described by Shapes or ShapeContours). A simple benchmark shows a // speed-up of roughly 300% - if (boundsMapper != null && !boundsMapper(width, height, c)) continue@candidateSearch + if (boundsMapper != null && !boundsMapper(c)) continue@candidateSearch - addPoint(c) + addPoint(c, radius) candidateAccepted = true break } // If no candidate was accepted, remove the sample from the active list if (!candidateAccepted) { - queue.remove(activeIndex) + queue.remove(queueItem) } } return disk diff --git a/orx-noise/src/commonMain/kotlin/ShapeNoise.kt b/orx-noise/src/commonMain/kotlin/ShapeNoise.kt index 21c84627..c018e50b 100644 --- a/orx-noise/src/commonMain/kotlin/ShapeNoise.kt +++ b/orx-noise/src/commonMain/kotlin/ShapeNoise.kt @@ -1,5 +1,6 @@ package org.openrndr.extra.noise +import org.openrndr.extra.hashgrid.HashGrid import org.openrndr.math.Vector2 import org.openrndr.shape.* import kotlin.random.Random @@ -19,58 +20,98 @@ fun ShapeProvider.uniform(distanceToEdge: Double = 0.0, random: Random = Random. } } -fun ShapeProvider.scatter( - pointDistance: Double, +fun ShapeProvider.multiScatter( + radii: List>, distanceToEdge: Double = 0.0, tries: Int = 30, random: Random = Random.Default +) : List>> { + + val obstacles = mutableListOf>>() + val result = mutableListOf>>() + for ((placementRadius, objectRadius) in radii) { + val points = scatter(placementRadius, objectRadius, distanceToEdge, tries, obstacles, random) + obstacles.add(Pair(objectRadius, points)) + result.add(Pair(objectRadius, points)) + } + return result +} + + +fun ShapeProvider.scatter( + placementRadius: Double, + objectRadius: Double = placementRadius, + distanceToEdge: Double = 0.0, + tries: Int = 30, + obstacles: List>> = emptyList(), + random: Random = Random.Default ): List { val shape = shape if (shape.empty) { return emptyList() } val bounds = shape.bounds - val poissonBounds = Rectangle(0.0, 0.0, bounds.width, bounds.height) - val initialPoints = shape.splitCompounds().flatMap { compound -> - compound.outline.segments.map { + val obstacleHashGrids = obstacles.map { (obstacleRadius, points) -> + val hg = HashGrid(obstacleRadius + objectRadius) + for (point in points) { + hg.insert(point) + } + hg + } + + fun Segment.randomPoints(count: Int) = sequence { + for (i in 0 until count) { val t = random.nextDouble() - (it.position(t) - it.normal(t).normalized * distanceToEdge) - }.filter { compound.contains(it) && compound.outline.nearest(it).position.distanceTo(it) >= distanceToEdge-1E-1 }.map { - it.map(bounds, poissonBounds) + yield(position(t) - normal(t).normalized * distanceToEdge) } } + + val initialPointHashGrid = HashGrid(placementRadius) + val initialPoints = shape.splitCompounds().flatMap { compound -> + compound.outline.segments.mapNotNull { + val point = it.randomPoints(20).firstOrNull { v -> + obstacleHashGrids.all { ohg -> ohg.isFree(v) } && + initialPointHashGrid.isFree(v) && + compound.contains(v) && + compound.outline.nearest(v).position.distanceTo(v) >= distanceToEdge - 1E-1 + } + if (point != null) { + initialPointHashGrid.insert(point) + } + point + } + } + + require(initialPoints.isNotEmpty() || obstacles.isNotEmpty()) + val candidatePoints = mutableListOf() for (point in initialPoints) { - if ((candidatePoints.map { it.distanceTo(point) }.minOrNull() ?: Double.POSITIVE_INFINITY) >= pointDistance) { + if ((candidatePoints.map { it.distanceTo(point) }.minOrNull() ?: Double.POSITIVE_INFINITY) >= placementRadius * 2.0) { candidatePoints.add(point) } } - if (candidatePoints.isEmpty()) { return emptyList() } return poissonDiskSampling( - bounds.width, - bounds.height, - pointDistance, + bounds, + placementRadius * 2.0, tries, - false, + true, random, candidatePoints, - ) { _, _, point -> - val contourPoint = point.map(poissonBounds, bounds) + obstacleHashGrids = obstacleHashGrids, + ) { point -> if (distanceToEdge == 0.0) { - shape.contains(contourPoint) + shape.contains(point) } else { - shape.contains(contourPoint) && shape.contours.minOf { c -> - c.nearest(contourPoint).position.distanceTo(contourPoint) + shape.contains(point) && shape.contours.minOf { c -> + c.nearest(point).position.distanceTo(point) } > distanceToEdge } - }.map { - it.map(poissonBounds, bounds) } } \ No newline at end of file diff --git a/orx-noise/src/demo/kotlin/DemoPoissonDiskSampling.kt b/orx-noise/src/demo/kotlin/DemoPoissonDiskSampling.kt deleted file mode 100644 index 8703fb42..00000000 --- a/orx-noise/src/demo/kotlin/DemoPoissonDiskSampling.kt +++ /dev/null @@ -1,28 +0,0 @@ -import org.openrndr.application -import org.openrndr.color.ColorRGBa -import org.openrndr.extra.noise.poissonDiskSampling -import org.openrndr.math.Vector2 -import org.openrndr.shape.Circle - -fun main() { - application { - program { - var points = poissonDiskSampling(200.0, 200.0, 5.0, 10) - val rectPoints = points.map { Circle(Vector2(100.0, 100.0) + it, 3.0) } - - points = poissonDiskSampling(200.0, 200.0, 5.0, 10, true) { w: Double, h: Double, v: Vector2 -> - Circle(Vector2(w, h) / 2.0, 100.0).contains(v) - } - - val circlePoints = points.map { Circle(Vector2(350.0, 100.0) + it, 3.0) } - - extend { - drawer.clear(ColorRGBa.BLACK) - drawer.stroke = null - drawer.fill = ColorRGBa.PINK - drawer.circles(rectPoints) - drawer.circles(circlePoints) - } - } - } -} \ No newline at end of file