[orx-noise] Switch poissonDiskSampling to use HashGrid, add multiScatter

This commit is contained in:
Edwin Jakobs
2022-01-06 23:23:46 +01:00
parent 5332d8bcf4
commit a760df8849
4 changed files with 96 additions and 119 deletions

View File

@@ -23,6 +23,7 @@ kotlin {
kotlin.srcDir("src/demo") kotlin.srcDir("src/demo")
dependencies { dependencies {
implementation(project(":orx-camera")) implementation(project(":orx-camera"))
implementation(project(":orx-hash-grid"))
implementation("org.openrndr:openrndr-application:$openrndrVersion") implementation("org.openrndr:openrndr-application:$openrndrVersion")
implementation("org.openrndr:openrndr-extensions:$openrndrVersion") implementation("org.openrndr:openrndr-extensions:$openrndrVersion")
runtimeOnly("org.openrndr:openrndr-gl3:$openrndrVersion") runtimeOnly("org.openrndr:openrndr-gl3:$openrndrVersion")
@@ -56,6 +57,8 @@ kotlin {
implementation("org.openrndr:openrndr-math:$openrndrVersion") implementation("org.openrndr:openrndr-math:$openrndrVersion")
implementation("org.openrndr:openrndr-shape:$openrndrVersion") implementation("org.openrndr:openrndr-shape:$openrndrVersion")
implementation("org.openrndr:openrndr-draw:$openrndrVersion") implementation("org.openrndr:openrndr-draw:$openrndrVersion")
implementation(project(":orx-hash-grid"))
} }
} }
@Suppress("UNUSED_VARIABLE") @Suppress("UNUSED_VARIABLE")

View File

@@ -1,11 +1,9 @@
package org.openrndr.extra.noise package org.openrndr.extra.noise
import org.openrndr.extra.hashgrid.HashGrid
import org.openrndr.math.Polar import org.openrndr.math.Polar
import org.openrndr.math.Vector2 import org.openrndr.math.Vector2
import org.openrndr.math.clamp
import org.openrndr.shape.Rectangle import org.openrndr.shape.Rectangle
import kotlin.math.ceil
import kotlin.math.sqrt
import kotlin.random.Random 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 * 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 * They can also be generated on a ring like in the original algorithm from Robert Bridson
* *
* @param width the width of the area * @param bounds the rectangular bounds of the area to generate points in
* @param height the height of the area
* @param r the minimum distance between each point * @param r the minimum distance between each point
* @param tries number of candidates per point * @param tries number of candidates per point
* @param randomOnRing generate random points on a ring with an annulus from r to 2r * @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 * @return a list of points
*/ */
fun poissonDiskSampling( fun poissonDiskSampling(
width: Double, bounds: Rectangle,
height: Double, radius: Double,
r: Double, tries: Int = 30,
tries: Int = 30, randomOnRing: Boolean = true,
randomOnRing: Boolean = false, random: Random = Random.Default,
random: Random = Random.Default, initialPoints: List<Vector2> = listOf(bounds.center),
initialPoints: List<Vector2> = listOf(Vector2(width/2.0, height/2.0)), obstacleHashGrids: List<HashGrid> = emptyList(),
boundsMapper: ((w: Double, h: Double, v: Vector2) -> Boolean)? = null, boundsMapper: ((v: Vector2) -> Boolean)? = null,
): List<Vector2> { ): List<Vector2> {
val disk = mutableListOf<Vector2>() val disk = mutableListOf<Vector2>()
val queue = mutableListOf<Int>() val queue = mutableSetOf<Pair<Vector2, Double>>()
val hashGrid = HashGrid(radius)
val r2 = r * r fun addPoint(v: Vector2, radius: Double) {
val radius = r + epsilon hashGrid.insert(v)
disk.add(v)
val cellSize = r / sqrt(2.0) queue.add(Pair(v, radius))
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)
}
} }
for (initialPoint in initialPoints) { 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()) { while (queue.isNotEmpty()) {
val activeIndex = queue.random(random) val queueItem = queue.random(random)
val active = disk[activeIndex] val (active, activeRadius) = queueItem
var candidateAccepted = false var candidateAccepted = false
candidateSearch@ for (l in 0 until tries) { candidateSearch@ for (l in 0 until tries) {
val c = if (randomOnRing) { val c = if (randomOnRing) {
active + Vector2.uniformRing(r, 2 * r, random) active + Vector2.uniformRing(activeRadius, 2 * activeRadius- epsilon, random)
} else { } 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() if (!hashGrid.isFree(c) || obstacleHashGrids.any { !it.isFree(c) })
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) {
continue@candidateSearch 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 // check if the candidate point is within bounds
// EJ: This is somewhat counter-intuitively moved to the last stage in the process; // 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 // 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 // case of complex bounds (such as described by Shapes or ShapeContours). A simple benchmark shows a
// speed-up of roughly 300% // 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 candidateAccepted = true
break break
} }
// If no candidate was accepted, remove the sample from the active list // If no candidate was accepted, remove the sample from the active list
if (!candidateAccepted) { if (!candidateAccepted) {
queue.remove(activeIndex) queue.remove(queueItem)
} }
} }
return disk return disk

View File

@@ -1,5 +1,6 @@
package org.openrndr.extra.noise package org.openrndr.extra.noise
import org.openrndr.extra.hashgrid.HashGrid
import org.openrndr.math.Vector2 import org.openrndr.math.Vector2
import org.openrndr.shape.* import org.openrndr.shape.*
import kotlin.random.Random import kotlin.random.Random
@@ -19,58 +20,98 @@ fun ShapeProvider.uniform(distanceToEdge: Double = 0.0, random: Random = Random.
} }
} }
fun ShapeProvider.scatter( fun ShapeProvider.multiScatter(
pointDistance: Double, radii: List<Pair<Double, Double>>,
distanceToEdge: Double = 0.0, distanceToEdge: Double = 0.0,
tries: Int = 30, tries: Int = 30,
random: Random = Random.Default random: Random = Random.Default
) : List<Pair<Double, List<Vector2>>> {
val obstacles = mutableListOf<Pair<Double, List<Vector2>>>()
val result = mutableListOf<Pair<Double, List<Vector2>>>()
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<Pair<Double, List<Vector2>>> = emptyList(),
random: Random = Random.Default
): List<Vector2> { ): List<Vector2> {
val shape = shape val shape = shape
if (shape.empty) { if (shape.empty) {
return emptyList() return emptyList()
} }
val bounds = shape.bounds val bounds = shape.bounds
val poissonBounds = Rectangle(0.0, 0.0, bounds.width, bounds.height)
val initialPoints = shape.splitCompounds().flatMap { compound -> val obstacleHashGrids = obstacles.map { (obstacleRadius, points) ->
compound.outline.segments.map { 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() val t = random.nextDouble()
(it.position(t) - it.normal(t).normalized * distanceToEdge) yield(position(t) - normal(t).normalized * distanceToEdge)
}.filter { compound.contains(it) && compound.outline.nearest(it).position.distanceTo(it) >= distanceToEdge-1E-1 }.map {
it.map(bounds, poissonBounds)
} }
} }
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<Vector2>() val candidatePoints = mutableListOf<Vector2>()
for (point in initialPoints) { 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) candidatePoints.add(point)
} }
} }
if (candidatePoints.isEmpty()) { if (candidatePoints.isEmpty()) {
return emptyList() return emptyList()
} }
return poissonDiskSampling( return poissonDiskSampling(
bounds.width, bounds,
bounds.height, placementRadius * 2.0,
pointDistance,
tries, tries,
false, true,
random, random,
candidatePoints, candidatePoints,
) { _, _, point -> obstacleHashGrids = obstacleHashGrids,
val contourPoint = point.map(poissonBounds, bounds) ) { point ->
if (distanceToEdge == 0.0) { if (distanceToEdge == 0.0) {
shape.contains(contourPoint) shape.contains(point)
} else { } else {
shape.contains(contourPoint) && shape.contours.minOf { c -> shape.contains(point) && shape.contours.minOf { c ->
c.nearest(contourPoint).position.distanceTo(contourPoint) c.nearest(point).position.distanceTo(point)
} > distanceToEdge } > distanceToEdge
} }
}.map {
it.map(poissonBounds, bounds)
} }
} }

View File

@@ -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)
}
}
}
}