feat: implement blue noise generation for initial creature positions

This commit is contained in:
B Wu 2024-08-16 09:46:05 -07:00
parent 7e98ec63ac
commit c37d8d13c7
1 changed files with 137 additions and 24 deletions

View File

@ -6,6 +6,7 @@ const FRAME_RATE = 6; // in FPS
const FRAME_DELAY = 1 / FRAME_RATE * 1000; // in ms const FRAME_DELAY = 1 / FRAME_RATE * 1000; // in ms
const DEFAULT_SPRITE_SHEET_COUNT = 1; // number of default sprite sheets const DEFAULT_SPRITE_SHEET_COUNT = 1; // number of default sprite sheets
const WALK_SIZE = 8; // magnitude of position change, in px const WALK_SIZE = 8; // magnitude of position change, in px
const MAX_RETRIES = 10;
/** /**
* Enum of directions * Enum of directions
@ -274,6 +275,120 @@ const isBetween = (val, min, max) => {
return val >= min && val < max; return val >= min && val < max;
} }
/**
* Generate an array of n vec2's such that all vectors lie in
* [left, left + width) x [top, top + height) and are at least radius
* distance away from all other points.
* This function uses almost (but not quite)
* [the poisson disk sampling method]{@link https://a5huynh.github.io/posts/2019/poisson-disk-sampling/}
* insofar that instead of generating points in an annulus around a seed,
* it generates points anywhere else in the bounds.
* @param {number} n the number of points to generate
* @param {number} radius the minimum distance between points
* @param {number} left x lower bound of points
* @param {number} top y lower bound of points
* @param {number} width x width of bounds
* @param {number} height y width of bounds
* @param {number=} maxRetries maximum number of generation attemps before giving up
* @return {Array<vec2>}
*/
const generateBlueNoise = (n, radius, left, top, width, height, maxRetries = MAX_RETRIES) => {
const gridCellSize = radius / Math.sqrt(2);
const gridWidth = Math.ceil(width / gridCellSize);
const gridHeight = Math.ceil(height / gridCellSize);
const grid = /** @type {Array<Array<vec2|undefined>>} */ [...Array(gridHeight)].map(() => Array(gridWidth));
/**
* @param {vec2} point
* @return {vec2}
*/
const getSamplePoint = (point) => {
const randomX = Math.random() * (width - 2 * radius);
const randomY = Math.random() * (height - 2 * radius);
const samplePointX = left + (
randomX < (point.at(0) - radius) ? randomX : randomX + 2 * radius
)
const samplePointY = top + (
randomY < (point.at(1) - radius) ? randomY : randomY + 2 * radius
)
return [samplePointX, samplePointY];
}
/**
* @param {vec2} point
* @return {void}
*/
const insertPointIntoGrid = (point) => {
const indexX = Math.floor((point.at(0) - left) / gridCellSize);
const indexY = Math.floor((point.at(1) - top) / gridCellSize);
grid[indexY][indexX] = point;
}
const areValid = (point1, point2) => {
const deltaX = point1.at(0) - point2.at(0);
const deltaY = point1.at(1) - point2.at(1);
return (deltaX * deltaX + deltaY * deltaY) > (radius * radius)
}
/**
* @param {vec2} point
* @return {boolean}
*/
const isValidPoint = (point) => {
const indexX = Math.floor((point.at(0) - left) / gridCellSize);
const indexY = Math.floor((point.at(1) - top) / gridCellSize);
const testPoints = [
[indexX - 1, indexY - 1], [indexX, indexY - 1], [indexX + 1, indexY - 1],
[indexX - 1, indexY], [indexX, indexY], [indexX + 1, indexY],
[indexX - 1, indexY + 1], [indexX, indexY + 1], [indexX + 1, indexY + 1],
]
.filter((indices) => (
indices.at(0) >= 0 && indices.at(1) >= 0 && indices.at(0) < gridWidth && indices.at(1) < gridHeight
))
.map((indices) => grid[indices.at(1)][indices.at(0)])
.filter((point) => point !== undefined);
return testPoints.every((testPoint) => areValid(point, testPoint))
}
// initialize
const initialPoint = [
left + width / 2, top + height / 2,
]
insertPointIntoGrid(initialPoint);
const blueNoiseSamples = [initialPoint];
const activeList = [initialPoint];
const findAndInsertPoint = () => {
if (activeList.length === 0) return;
const activePoint = /** @type {vec2} */ getRandomChoice(undefined, activeList);
const samplePoints = [...Array(MAX_RETRIES)].map(_ => getSamplePoint(activePoint));
const samplePoint = samplePoints.find(isValidPoint);
if (samplePoint === undefined) {
// remove active point from active list
const indexOfActivePoint = activeList.indexOf(activePoint);
activeList.splice(indexOfActivePoint, 1);
return;
}
activeList.push(samplePoint);
blueNoiseSamples.push(samplePoint);
insertPointIntoGrid(samplePoint);
}
while (activeList.length > 0 && blueNoiseSamples.length < n) {
findAndInsertPoint();
}
return blueNoiseSamples.length < n ? [] : blueNoiseSamples;
}
// === </MATH_UTILS> === // === </MATH_UTILS> ===
// === <CREATURE_UTILS> === // === <CREATURE_UTILS> ===
@ -416,7 +531,6 @@ const createCreature = (
* Start creature animation * Start creature animation
* @param {Creature} creature * @param {Creature} creature
* @param {(creature: Creature) => Creature} [updateCreature] if undefined, uses identity * @param {(creature: Creature) => Creature} [updateCreature] if undefined, uses identity
* (via {@link renderAndUpdateCreature})
*/ */
const beginCreatureAnimation = ( const beginCreatureAnimation = (
creature, creature,
@ -441,8 +555,8 @@ const beginCreatureAnimation = (
*/ */
const isCreaturesColliding = (creature1, creature2) => { const isCreaturesColliding = (creature1, creature2) => {
return isIntersectingCircles( return isIntersectingCircles(
creature1.positionX, creature1.positionY, CREATURE_WIDTH * 1/2, creature1.positionX, creature1.positionY, CREATURE_WIDTH / 2,
creature2.positionX, creature2.positionY, CREATURE_WIDTH * 1/2, creature2.positionX, creature2.positionY, CREATURE_WIDTH / 2,
) )
} }
@ -771,35 +885,34 @@ const resolveCollision = (updatedCreature, allCreatures, creature) => {
} }
window.onload = () => { window.onload = () => {
if ( // user preference for low motion. do not render creatures
window.matchMedia(`(prefers-reduced-motion: reduce)`).matches if (window.matchMedia(`(prefers-reduced-motion: reduce)`).matches) {
|| window.matchMedia(`(prefers-reduced-motion: reduce)`).matches
) {
// user preference for low motion. do not render creatures
return; return;
} }
const TEST_CREATURE_COUNT = 32;
const kennelWindowEle = document.querySelector('#kennel-window'); const kennelWindowEle = document.querySelector('#kennel-window');
const creatures = Array(32).fill('test-creature')
.map((name, index) => createCreature( const startingPoints = generateBlueNoise(
kennelWindowEle, TEST_CREATURE_COUNT, CREATURE_WIDTH,
name + index.toString(), kennelWindowEle.clientLeft, kennelWindowEle.clientTop,
undefined, // default sprite sheet kennelWindowEle.clientWidth, kennelWindowEle.clientHeight
getRandomChoice(undefined, [ )
const creatures = /** @type {Array<Creature>} */ startingPoints
.map((startingPoint, index) => createCreature(
kennelWindowEle,
`test-creature-${index}`,
undefined, // use a default sprite sheet
CreatureState.IDLE, CreatureState.IDLE,
CreatureState.TIRED, startingPoint.at(0),
CreatureState.SCRATCH_SELF, startingPoint.at(1),
]),
getRandomInRange(kennelWindowEle.clientLeft, kennelWindowEle.clientLeft + kennelWindowEle.clientWidth),
getRandomInRange(kennelWindowEle.clientTop, kennelWindowEle.clientTop + kennelWindowEle.clientHeight),
)) ))
const updateCreature = updateCreatureWithContext((creature, allCreatures) => { const updateCreature = updateCreatureWithContext((creature, allCreatures) => {
return resolveCollision( const updateCreatureFunction = CREATURE_STATE_TO_UPDATE_CREATURE_FUNCTION[creature.state];
CREATURE_STATE_TO_UPDATE_CREATURE_FUNCTION[creature.state](creature, allCreatures), const updatedCreature = updateCreatureFunction(creature, allCreatures);
allCreatures, return resolveCollision(updatedCreature, allCreatures, creature);
creature,
);
}, creatures); }, creatures);
creatures.forEach((creature) => beginCreatureAnimation(creature, updateCreature)) creatures.forEach((creature) => beginCreatureAnimation(creature, updateCreature))