initial commit

This commit is contained in:
Rowan 2025-03-28 18:06:18 -05:00
commit c039544ec6
47 changed files with 2512 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
http/
serve

11
.gitmodules vendored Normal file
View file

@ -0,0 +1,11 @@
[submodule "public/vendor/bitecs"]
path = public/vendor/bitecs
url = https://github.com/NateTheGreatt/bitECS.git
branch = rc-0-4-0
[submodule "public/vendor/gl-matrix"]
path = public/vendor/gl-matrix
url = https://github.com/toji/gl-matrix.git
branch = v3.4.x
[submodule "public/vendor/kojima"]
path = public/vendor/kojima
url = https://git.kitsu.cafe/rowan/kojima.git

17
examples/cube/index.html Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>HTML 5 Boilerplate</title>
<link rel="stylesheet" href="/public/css/style.css">
</head>
<body>
<main>
<canvas></canvas>
</main>
<script type="module" src="index.js"></script>
</body>
</html>

205
examples/cube/index.js Normal file
View file

@ -0,0 +1,205 @@
import { addComponent, addEntity, createWorld, query } from '/src/ecs.js'
import { Engine, Startup, Update, World } from '/src/core/engine.js'
import { DefaultPlugins } from '/src/plugins/index.js'
import { WebGLContext } from '/src/plugins/renderer/webgl-context.js'
import { Camera, Mesh, Renderable, Transform } from '/src/components/index.js'
import * as twgl from '/public/vendor/twgl/twgl-full.module.js'
import { Assets, AssetType } from '/src/core/assets.js'
import { Geometry, Material, Materials, Meshes } from '/src/plugins/renderer/index.js'
/** @param {number} degrees */
const deg2rad = degrees => degrees * Math.PI / 180
// INFO: does a camera need a Transform, specifically
// a scale??
/** @param {World} world */
const createCamera = world => {
const entity = addEntity(world)
addComponent(world, entity, [Camera, Transform])
const transform = twgl.m4.identity()
twgl.m4.translate(transform, [0, 0, 10], transform)
Transform.matrix[entity] = transform
Camera.projectionMatrix[entity] = []
Camera.viewMatrix[entity] = []
Camera.up[entity] = new Float32Array([0, 1, 0])
Camera.fov[entity] = deg2rad(30)
Camera.zNear[entity] = 0.1
Camera.zFar[entity] = 1000
return entity
}
const Cube = () => {
const position = [
// Front face
-0.5, -0.5, 0.5, 0.5, -0.5, 0.5, 0.5, 0.5, 0.5, -0.5, 0.5, 0.5,
// Back face
-0.5, -0.5, -0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, 0.5, -0.5, -0.5,
// Top face
-0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, -0.5,
// Bottom face
-0.5, -0.5, -0.5, 0.5, -0.5, -0.5, 0.5, -0.5, 0.5, -0.5, -0.5, 0.5,
// Right face
0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5, 0.5, 0.5, 0.5, -0.5, 0.5,
// Left face
-0.5, -0.5, -0.5, -0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, 0.5, -0.5,
]
const indices = [
// Front face
0, 1, 2, 0, 2, 3,
// Back face
4, 5, 6, 4, 6, 7,
// Top face
8, 9, 10, 8, 10, 11,
// Bottom face
12, 13, 14, 12, 14, 15,
// Right face
16, 17, 18, 16, 18, 19,
// Left face
20, 21, 22, 20, 22, 23,
]
const color = [
// Front face (red)
1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0,
// Back face (green)
0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0,
// Top face (blue)
0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1,
// Bottom face (yellow)
1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0,
// Right face (magenta)
1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1,
// Left face (cyan)
0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1,
]
return {
position,
indices,
color: { numComponents: 3, data: color }
}
}
const Quad = () => {
const position = [
-0.5, -0.5, 0,
0.5, -0.5, 0,
0.5, 0.5, 0,
-0.5, 0.5, 0,
]
const indices = [
0, 1, 2,
2, 3, 0,
]
const color = [
1, 0, 0,
0, 1, 0,
0, 0, 1,
1, 1, 0,
]
return { position, indices, color: { numComponents: 3, data: color } }
}
const Triangle = () => {
return {
position: [-0.5, -0.5, 0, 0.5, -0.5, 0, 0, 0.5, 0],
color: { numComponents: 3, data: [1, 0, 0, 0, 1, 0, 0, 0, 1] },
indices: [0, 1, 2]
}
}
const createObject = (world, mesh, material, position) => {
const entity = addEntity(world)
addComponent(world, entity, [Renderable, Mesh, Transform])
const transform = twgl.m4.identity()
twgl.m4.translate(transform, position, transform)
Transform.matrix[entity] = transform
Mesh.geometry[entity] = mesh
Mesh.material[entity] = material
return entity
}
/** @type {HTMLCanvasElement} */
const canvas = document.querySelector('main canvas')
if (canvas.getContext) {
const world = createWorld(new World())
let app = new Engine(world, canvas)
.addResource(new WebGLContext(canvas))
.addResource(new Assets())
.addPlugin(DefaultPlugins)
.addSystem(Startup, async world => {
const gl = world.getResource(WebGLContext).context
const meshes = world.getResource(Meshes)
const materials = world.getResource(Materials)
const assets = world.getResource(Assets)
const camera = createCamera(world)
let sources = ['simple-vert.wgsl', 'simple-frag.wgsl']
.map(url => `/public/shaders/${url}`)
.map(import.meta.resolve)
.map(url => assets.load(url, AssetType.Text))
sources = await Promise.all(sources)
const mesh = Cube()
const geometry = new Geometry(mesh.position, mesh.indices).createBuffers()
const meshHandle = meshes.add(buffer)
const materialHandle = materials.add(new Material(
programInfo,
{ u_color: [1, 1, 1, 1] }
))
const one = createObject(world, meshHandle, materialHandle, [0, -1.5, 0])
let transform = Transform.matrix[one]
twgl.m4.rotateX(transform, deg2rad(45), transform)
twgl.m4.rotateY(transform, deg2rad(45), transform)
const two = createObject(world, meshHandle, materialHandle, [2, 1, 0])
transform = Transform.matrix[two]
twgl.m4.rotateX(transform, deg2rad(135), transform)
twgl.m4.rotateY(transform, deg2rad(135), transform)
const three = createObject(world, meshHandle, materialHandle, [-2, 1, 0])
transform = Transform.matrix[three]
twgl.m4.rotateX(transform, deg2rad(225), transform)
twgl.m4.rotateY(transform, deg2rad(225), transform)
})
.addSystem(Update, world => {
const meshes = query(world, [Mesh, Transform])
for (const mesh of meshes) {
const transform = Transform.matrix[mesh]
twgl.m4.rotateZ(transform, 0.01, transform)
}
})
.run()
} else {
// canvas not supported
}

17
index.html Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>HTML 5 Boilerplate</title>
<link rel="stylesheet" href="public/css/style.css">
</head>
<body>
<main>
<canvas></canvas>
</main>
<script type="module" src="src/index.js"></script>
</body>
</html>

16
jsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"module": "es2020",
"target": "es6",
"lib": ["es2019", "dom"],
"types": ["@webgpu/types"],
"checkJs": true,
"paths": {
"/*": ["./*"]
}
},
"exclude": [
"node_modules"
]
}

19
package-lock.json generated Normal file
View file

@ -0,0 +1,19 @@
{
"name": "canvas",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"@webgpu/types": "^0.1.58"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.58",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.58.tgz",
"integrity": "sha512-+8+NBE17zrc1wS4FvZmmuGTpog5C2H6QC46RY2TTWNpnt15Xvp7dZoUHZQXvb8l5ddKwO8l1pDJa6XTnR4Al1Q==",
"dev": true,
"license": "BSD-3-Clause"
}
}
}

5
package.json Normal file
View file

@ -0,0 +1,5 @@
{
"devDependencies": {
"@webgpu/types": "^0.1.58"
}
}

13
public/css/style.css Normal file
View file

@ -0,0 +1,13 @@
html, body {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
main canvas {
aspect-ratio: 16 / 9;
width: min(100%, 100vh * 16 / 9);
height: 100%;
}

View file

@ -0,0 +1,17 @@
struct Uniforms {
u_color: vec4f,
};
@group(0) @binding(0)
var<uniform> uniforms: Uniforms;
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) v_color: vec4f,
};
@fragment
fn main(input: VertexOutput) -> @location(0) vec4f {
return input.v_color * uniforms.u_color;
}

View file

@ -0,0 +1,27 @@
struct VertexInput {
@location(0) position: vec4f,
@location(1) color: vec4f,
};
struct Uniforms {
u_projection: mat4x4f,
u_view: mat4x4f,
u_world: mat4x4f,
};
@group(0) @binding(0)
var<uniform> uniforms: Uniforms;
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) v_color: vec4f,
};
@vertex
fn main(input: VertexInput) -> VertexOutput {
var out: VertexOutput;
out.position = uniforms.u_projection * uniforms.u_view * uniforms.u_world * input.position;
out.v_color = input.color;
return out;
}

1
public/vendor/bitecs vendored Submodule

@ -0,0 +1 @@
Subproject commit caa1f58be2ccc304c1f0a085de34ca5904b3b80f

1
public/vendor/gl-matrix vendored Submodule

@ -0,0 +1 @@
Subproject commit 4e312f346713acd87dcb8aef9016121108eac07a

1
public/vendor/kojima vendored Submodule

@ -0,0 +1 @@
Subproject commit 1224a8382cf7ec0e9409430f7185d0f857a2ec26

9
src/components/color.js Normal file
View file

@ -0,0 +1,9 @@
import { DefaultSize } from './constants.js'
export const Rgba8 = {
r: new Uint8Array(DefaultSize),
g: new Uint8Array(DefaultSize),
b: new Uint8Array(DefaultSize),
a: new Uint8Array(DefaultSize),
}

View file

@ -0,0 +1,2 @@
export const DefaultSize = 512

6
src/components/index.js Normal file
View file

@ -0,0 +1,6 @@
export * from './math.js'
export * from './color.js'
export * from '../plugins/camera/components/index.js'
export * from '../plugins/renderer/components/index.js'
export * from '../plugins/transform/components/index.js'

105
src/components/math.js Normal file
View file

@ -0,0 +1,105 @@
import * as twgl from '../../public/vendor/twgl/twgl-full.module.js'
const identity = twgl.m4.identity()
const id = (size, m) => {
const len = m.length
for (let i = 0; i < len; i += size) {
m.set(identity, i)
}
return m
}
export const Range = size => ({
min: new Float32Array(size),
max: new Float32Array(size)
})
export const UVector2 = size => []
export const UVector3 = size => []
export const Vector2 = size => []
export const Vector3 = size => []
export const Matrix3x3 = size => []
export const Matrix4x4 = size => []
export class ComponentProxy {
id
_component
/**
* @param {number} id
* @param {import('../ecs.js').Component} component
*/
constructor(id, component) {
this.id = id
this._component = component
}
valueOf() {
return this._component[this.id]
}
}
export class ArrayComponentProxy extends ComponentProxy {
_length
/**
* @param {number} id
* @param {import('../ecs.js').Component} component
* @param {number} length
*/
constructor(id, component, length) {
super(id, component)
this._length = length
}
valueOf() {
return this._component.subarray(this.id, this.id + this._length)
}
}
export class Vector2Proxy extends ArrayComponentProxy {
/*
* @param {number} id
* @param {import('../ecs.js').Component} [component=Vector2]
* @param {number} [length=2]
*/
constructor(id, component = Vector2, length = 2) {
super(id, component, length)
}
get x() {
return this._component[this.id]
}
get y() {
return this._component[this.id + 1]
}
}
export class Vector3Proxy extends Vector2Proxy {
/*
* @param {number} id
* @param {import('../ecs.js').Component} [component=Vector3]
* @param {number} [length=3]
*/
constructor(id, component = Vector3, length = 3) {
super(id, component, length)
}
get z() {
return this._component[this.id + 2]
}
}
export class Matrix3x3Proxy extends ArrayComponentProxy {
constructor(id, component = Matrix3x3) {
super(id, component, 9)
}
}
export class Matrix4x4Proxy extends ArrayComponentProxy {
constructor(id, component = Matrix4x4) {
super(id, component, 16)
}
}

112
src/core/assets.js Normal file
View file

@ -0,0 +1,112 @@
import { NotImplementedError } from '../utils/error.js'
export const AssetType = Object.freeze({
Text: url => fetch(url).then(res => res.text()),
ArrayBuffer: url => fetch(url).then(res => res.arrayBuffer()),
Image: url => new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = err => reject(err)
image.src = url
})
})
class StorageLayer {
has(_key) {
throw new NotImplementedError()
}
get(_key) {
throw new NotImplementedError()
}
set(_key, _value) {
throw new NotImplementedError()
}
}
class MemoryStorage extends StorageLayer {
#memory = new Map()
has(key) {
return this.#memory.has(key)
}
get(key) {
return this.#memory.get(key)
}
set(key, value) {
this.#memory.set(key, value)
}
}
class LocalStorage extends StorageLayer {
#cache = new MemoryStorage()
has(key) {
if (this.#cache.has(key)) {
return true
} else {
const item = localStorage.getItem(key)
if (item != null) {
this.#cache.set(key, item)
return true
}
return false
}
}
get(key) {
if (this.#cache.has(key)) {
return this.#cache.get(key)
} else {
const item = localStorage.getItem(key)
if (item != null) {
this.#cache.set(key, item)
return item
}
return null
}
}
set(key, value) {
this.#cache.set(key, value)
localStorage.setItem(key, value)
}
}
export class Assets {
#storage = new LocalStorage()
/**
*
* @param {UrlLike} url
* @param {(url: UrlLike) => Promise<any>} handler
*/
async load(url, handler = AssetType.ArrayBuffer) {
if (this.#storage.has(url)) {
return this.#storage.get(url)
}
try {
const data = await handler(url)
this.#storage.set(url, data)
return data
} catch (err) {
console.error(err)
}
}
/**
* @param {UrlLike} url
*/
get(url) {
return this.#storage.get(url)
}
}

24
src/core/ecs.js Normal file
View file

@ -0,0 +1,24 @@
import { isObject } from './utils/index.js'
import { addComponent as add } from '../public/vendor/bitecs/index.js'
import { World } from './core/engine.js'
export * from '../public/vendor/bitecs/index.js'
/** @typedef {import('./core/engine.js').Plugin} Plugin */
/** @typedef {any} Component
/**
* @param {World} world
* @param {number} entity
* @param {Component} component
*/
export const addComponent = (world, entity, component) => {
if (Array.isArray(component)) {
const len = component.length
for (let i = 0; i < len; i++) {
add(world, entity, component[i])
}
} else {
add(world, entity, component)
}
}

217
src/core/engine.js Normal file
View file

@ -0,0 +1,217 @@
import { addComponent, addEntity } from '../ecs.js'
import { PrioritySchedule, SystemSchedule, Schedules } from './schedule.js'
/** @typedef {(engine: Engine) => void} Plugin */
/** @typedef {number} Entity */
export class Startup { }
export class Update { }
export class FixedUpdate { }
export class Render { }
export class Time {
/** @type {DOMHighResTimeStamp} */
currentTime
/** @type {DOMHighResTimeStamp} */
deltaTime
}
class Resources {
#inner = new Map()
/**
* @template T
* @param {T} resource
*/
add(resource) {
this.#inner.set(resource.constructor, resource)
}
/**
* @template T
* @param {new(...args: any[]) => T} resource
* @returns {T}
*/
get(resource) {
return this.#inner.get(resource)
}
}
export class World {
#resources = new Resources()
/**
* @template T
* @param {T} resource
*/
addResource(resource) {
this.#resources.add(resource)
}
/**
* @template T
* @param {new(...args: any[]) => T} resource
* @returns {T}
*/
getResource(resource) {
return this.#resources.get(resource)
}
}
export class Canvas {
/** @type {HTMLCanvasElement} */
#element
/**
* @param {HTMLCanvasElement} element
*/
constructor(element) {
this.#element = element
}
get element() {
return this.#element
}
}
// TODO: add analog to Commands/EntityCommands
export class Engine {
/** @type {World} */
#world
/** @type {Plugin[]} */
#plugins = []
#schedules = new Schedules()
/** @type {number} */
#loopId
#initialized = false
/**
* @param {World} world
* @param {HTMLCanvasElement} canvas
*/
constructor(world, canvas) {
this.#world = world
this.addResource(new Time())
this.addResource(new Canvas(canvas))
this.#schedules.add(
Startup,
new PrioritySchedule(0, new SystemSchedule())
)
this.#schedules.add(
Update,
new PrioritySchedule(1, new SystemSchedule())
)
this.#schedules.addBefore(
Update,
FixedUpdate,
new SystemSchedule()
)
this.#schedules.addAfter(
Update,
Render,
new SystemSchedule()
)
}
get world() {
return this.#world
}
/**
* @param {any} resource
* @returns {Engine}
*/
addResource(resource) {
this.#world.addResource(resource)
return this
}
/**
* @template T
* @param {new(...args: any[]) => T} resource
* @returns {T}
*/
getResource(resource) {
return this.#world.getResource(resource)
}
/**
* @param {import('./schedule.js').ScheduleLabel} schedule
* @param {import('./schedule').System} system
* @param {import('./schedule').RegisterSystemOptions} [options={}]
* @returns {Engine}
*/
addSystem(schedule, system, options) {
this.#schedules.get(schedule).register(system, options)
return this
}
/**
* @param {Plugin | Plugin[]} plugin
* @returns {Engine}
*/
addPlugin(plugin) {
if (Array.isArray(plugin)) {
plugin.forEach(this.addPlugin.bind(this))
} else {
plugin(this)
}
return this
}
stop() {
cancelAnimationFrame(this.#loopId)
this.#loopId = undefined
}
async run() {
// FIXME: modularize this
const startup = this.#schedules.get(Startup).schedule
const main = this.#schedules.get(Update).schedule
const fixed = this.#schedules.get(FixedUpdate).schedule
const render = this.#schedules.get(Render).schedule
const fixedTimeStep = 1000 / 60
let lastTime = performance.now()
let accumulator = 0
/** @type (time: DOMHighResTimeStamp) => void */
const loop = currentTime => {
const deltaTime = currentTime - lastTime
lastTime = currentTime
accumulator += deltaTime
const time = this.getResource(Time)
time.currentTime = currentTime
time.deltaTime = deltaTime
while (accumulator >= fixedTimeStep) {
fixed.execute(this.#world)
accumulator -= fixedTimeStep
}
main.execute(this.#world)
render.execute(this.#world)
this.#loopId = requestAnimationFrame(loop)
}
await startup.execute(this.#world)
this.#loopId = requestAnimationFrame(loop)
}
}

329
src/core/schedule.js Normal file
View file

@ -0,0 +1,329 @@
import { PriorityMap } from '../utils/ordered-map.js'
/** @typedef {Function} ScheduleLabel */
/**
* @template Dependency
*/
export class CyclicalDependencyError extends Error {
/**
* @param {Dependency} dependency
*/
constructor(dependency) {
super(`Cyclical dependency: ${dependency}`)
}
}
/**
* @template Dependency
*/
export class UnknownDependencyError extends Error {
/**
* @param {Dependency} dependency
*/
constructor(dependency) {
super(`Dependency ${dependency} not found`)
}
}
/**
* @template Node
*/
export class Visitor {
#nodes
#recursion
#visited
/**
* Create a Visitor
* @param {Iterable.<Node>} nodes
*/
constructor(nodes) {
this.#nodes = Array.from(nodes)
this.#visited = new Set()
this.#recursion = new Set()
}
/** @typedef {(node: Node) => void} VisitNode */
/** @typedef {(node: Node, visit: VisitNode) => void} HandleNode */
/**
* @param {Node} node
* @param {HandleNode} onVisit
*
* @throws {CircularDependencyError}
* Thrown if two nodes rely on one another
*/
_visitNode(node, onVisit) {
if (this.#recursion.has(node)) {
throw new CyclicalDependencyError(node)
}
if (this.#visited.has(node)) return
this.#visited.add(node)
this.#recursion.add(node)
onVisit(node, node => this._visitNode(node, onVisit))
this.#recursion.delete(node)
}
/**
* Visit all nodes with the onVisit callback
*
* @param {HandleNode} onVisit
* @returns {Node[]}
*/
visit(onVisit) {
const len = this.#nodes.length
for (let i = 0; i < len; i++) {
this._visitNode(this.#nodes[i], onVisit)
}
return Array.from(this.#visited.values())
}
/**
* Reset the visitor
*/
clear() {
this.#visited.clear()
this.#recursion.clear()
}
}
export class Schedule {
execute(world) {
throw new NotImplementedError()
}
}
/** @typedef {(world: any) => void} System */
/**
* @typedef {Object} SystemSchema
* @property {System} system
* @property {Set<System>} dependencies
* @property {Set<System>} before
* @property {Set<System>} after
*/
/**
*
* @typedef {Object} RegisterSystemOptions
* @property {System[]} [options.dependencies = []]
* @property {System} [options.before]
* @property {System} [options.after]
*/
export class SystemSchedule {
/** @type {Map<System, SystemSchema>} */
_graph = new Map()
/** @type {System[]?} */
_cache = undefined
/**
* @param {System} node
* @returns {SystemSchema}
*
* @throws {UnknownDependencyError}
* Thrown if the requested node is not in the dependency graph
*/
_tryGet(node) {
if (!this._graph.has(node)) {
throw new UnknownDependencyError(node)
}
return this._graph.get(node)
}
/**
* @param {SystemSchema} beforeNode
* @param {SystemSchema} afterNode
*/
_insert(beforeNode, afterNode) {
afterNode.before.add(beforeNode.system)
beforeNode.after.add(afterNode.system)
}
/**
* Register a new system with this schedule
*
* @param {System} system
* @param {RegisterSystemOptions} [options={}]
*/
register(system, { dependencies = [], before, after } = {}) {
this._cache = undefined
this._graph.set(system, { system, dependencies: new Set(dependencies), before: new Set(), after: new Set() })
const systemNode = this._graph.get(system)
for (const dep of dependencies) {
const depNode = this._tryGet(dep)
depNode.after.add(system)
}
if (before) {
const beforeNode = this._tryGet(before)
this._insert(systemNode, beforeNode)
}
if (after) {
const afterNode = this._tryGet(after)
this._insert(afterNode, systemNode)
}
}
/**
* @returns {System[]}
*
* @throws {UnknownDependencyError}
* Thrown if a dependency was not registered before resolution
*
* @throws {CircularDependencyError}
* Thrown if two nodes rely on one another
*/
resolve() {
if (this._cache) {
return this._cache
}
const visitor = new Visitor(this._graph.keys())
this._cache = visitor.visit(
(next, visit) => {
const node = this._graph.get(next)
for (const dep of node.dependencies) {
visit(dep)
}
for (const before of node.before) {
visit(before)
}
}
)
return this._cache
}
/**
* @param {any} world
*
* @throws {UnknownDependencyError}
* Thrown if a dependency was not registered before resolution
*
* @throws {CircularDependencyError}
* Thrown if two nodes rely on one another
*/
async execute(world) {
const systems = this.resolve()
const len = systems.length
for (let i = 0; i < len; i++) {
await systems[i](world)
}
}
}
export class PrioritySchedule {
/** @type {Number} */
priority
/** @type {SystemSchedule} */
schedule
/**
* @param {Number} priority
* @param {SystemSchedule} schedule
*/
constructor(priority, schedule) {
this.priority = priority
this.schedule = schedule
}
register(system, options) {
this.schedule.register(system, options)
}
async execute(world) {
await this.schedule.execute(world)
}
}
/**
* @param {[ScheduleLabel, PrioritySchedule]} value
* @return {number}
*/
const getPriority = value => value[1].priority
/**
* @extends PriorityMap<ScheduleLabel, PrioritySchedule>
*/
export class Schedules extends PriorityMap {
/** @type {Number} */
#lowest
/** @type {Number} */
#highest
constructor() {
super(getPriority)
this.#lowest = -Infinity
this.#highest = Infinity
}
/**
* @param {ScheduleLabel} target
* @param {ScheduleLabel} label
* @param {SystemSchedule} schedule
*/
addBefore(target, label, schedule) {
const targetSchedule = this.get(target)
let priority = this.#lowest
if (targetSchedule) {
priority = targetSchedule.priority - 1
}
this.add(label, new PrioritySchedule(priority, schedule))
}
/**
* @param {ScheduleLabel} target
* @param {ScheduleLabel} label
* @param {SystemSchedule} schedule
*/
addAfter(target, label, schedule) {
const targetSchedule = this.get(target)
let priority = this.#lowest
if (targetSchedule) {
priority = targetSchedule.priority + 1
}
this.add(label, new PrioritySchedule(priority, schedule))
}
/**
* @param {ScheduleLabel} label
* @param {PrioritySchedule} prioritySchedule
*/
add(label, prioritySchedule) {
const priority = prioritySchedule.priority
if (priority > this.#lowest) {
this.#lowest = priority
} else if (priority < this.#highest) {
this.#highest = priority
}
this.set(label, prioritySchedule)
}
}

4
src/mixins/index.js Normal file
View file

@ -0,0 +1,4 @@
export const mixin = (target, mixin) => {
Object.assign(target, mixin)
}

View file

@ -0,0 +1,78 @@
import { DefaultSize } from '../../../components/constants.js'
import { Vector3, Matrix4x4 } from '../../../components/math.js'
class RenderGraph {
#nodes = new Map()
#edges = new Map()
add(name, render, dependencies = []) {
this.node.set(name, render)
this.edges.set(name, dependencies)
}
update(world) {
const executed = new Set()
const order = this.sort()
if (!order) {
return
}
for (const node of order) {
const render = this.nodes.get(node)
render(world)
executed.add(node)
}
}
sort() {
const visited = new Set()
const stack = []
const cycles = new Set()
const visit = node => {
if (cycles.has(node)) {
return false
}
if (visited.has(node)) {
return true
}
visited.add(node)
cycles.add(node)
for (const dep of this.dependentsFor(name)) {
if (!visit(dep)) {
return false
}
}
cycles.delete(name)
stack.push(node)
}
for (const node in this.nodes) {
if (!visited.has(node) && !visit(node)) {
return
}
}
return stack.reverse()
}
dependentsFor(name) {
return this.edges.values()
.filter(deps => deps.includes(name))
}
}
export const Camera = {
projectionMatrix: Matrix4x4(),
viewMatrix: Matrix4x4(),
fov: new Float32Array(DefaultSize),
zNear: new Float32Array(DefaultSize),
zFar: new Float32Array(DefaultSize),
up: Vector3(),
}

View file

@ -0,0 +1,9 @@
import { Engine, Update } from '../../core/engine.js'
import { updateCamera } from './systems/index.js'
/** @param {Engine} engine */
export const CameraPlugin = engine => {
engine.addSystem(Update, updateCamera(engine.world))
console.log('hi from camera plugin!')
}

View file

@ -0,0 +1,3 @@
class RenderGraph {
}

View file

@ -0,0 +1,39 @@
import { World } from '../../../core/engine.js'
import { query } from '../../../ecs.js'
import { Transform } from '../../transform/components/index.js'
import { Camera } from '../components/index.js'
import { WebGLContext } from '../../renderer/webgl-context.js'
import * as twgl from '/public/vendor/twgl/twgl-full.module.js'
/** @param {World} world */
export const updateCamera = world => {
/** @type {WebGLContext} */
const glContext = world.getResource(WebGLContext)
/** @type {HTMLCanvasElement} */
// @ts-ignore
const canvas = glContext.context.canvas
/** @param {World} world */
return world => {
const cameras = query(world, [Camera, Transform])
const len = cameras.length
for (let i = 0; i < len; i++) {
const entity = cameras[i]
const projection = Camera.projectionMatrix[entity]
const transform = Transform[entity]
const view = Camera.viewMatrix[entity]
const fov = Camera.fov[entity]
const aspect = canvas.clientWidth / canvas.clientHeight
const zNear = Camera.zNear[entity]
const zFar = Camera.zFar[entity]
twgl.m4.perspective(fov, aspect, zNear, zFar, projection)
twgl.m4.inverse(transform, view)
}
}
}

16
src/plugins/index.js Normal file
View file

@ -0,0 +1,16 @@
import { Engine } from '../core/engine.js'
import { CameraPlugin } from './camera/index.js'
import { RendererPlugin } from './renderer/index.js'
import { TransformPlugin } from './transform/index.js'
import { WindowPlugin } from './window/index.js'
/** @param {Engine} engine */
export const DefaultPlugins = engine => {
engine.addPlugin([
CameraPlugin,
RendererPlugin,
TransformPlugin,
WindowPlugin
])
}

View file

@ -0,0 +1,8 @@
import { DefaultSize } from '/src/components/constants.js'
export const Renderable = {}
export const Mesh = {
geometry: new Uint8Array(DefaultSize),
material: new Uint8Array(DefaultSize),
}

View file

@ -0,0 +1,10 @@
import { Engine, Render } from '../../core/engine.js'
import { render } from './systems/index.js'
/**
* @param {Engine} engine
*/
export const RendererPlugin = engine => {
engine.addSystem(Render, render(engine.world))
}

View file

@ -0,0 +1,4 @@
import { Union } from '/public/vendor/kojima/union.js'
export const Indices = Union(['U16', 'U32'])

View file

@ -0,0 +1,65 @@
import { MeshAttributeData } from './vertex.js'
import { Indices } from './index.js'
import { assignOptions } from '/src/utils/index.js'
export const PrimitiveTopology = Object.freeze({
PointList: 'point-list',
LineList: 'line-list',
LineStrip: 'line-strip',
TriangleList: 'triangle-list',
TriangleStrip: 'triangle-strip'
})
export const IndexFormat = Object.freeze({
Uint16: 'uint16',
Uint32: 'uint32'
})
export const FrontFace = Object.freeze({
Ccw: 'ccw',
Cw: 'cw'
})
export const CullMode = Object.freeze({
None: 'none',
Front: 'front',
Back: 'back'
})
export class PrimitiveState {
#topology = PrimitiveTopology.TriangleList
#stripIndexFormat
#frontFace = FrontFace.Ccw
#cullMode = CullMode.None
#unclippedDepth = false
/**
* @param {GPUIndexFormat} stripIndexFormat
* @param {Object} options
* @param {GPUPrimitiveTopology} [options.topology]
* @param {GPUFrontFace} [options.frontFace]
* @param {GPUCullMode} [options.cullMode]
* @param {boolean} [options.unclippedDepth]
*/
constructor(stripIndexFormat, options) {
this.#stripIndexFormat = stripIndexFormat
assignOptions(this, options)
}
get topology() { return this.#topology }
get stripIndexFormat() { return this.#stripIndexFormat }
get frontFace() { return this.#frontFace }
get cullMode() { return this.#cullMode }
get unclippedDepth() { return this.#unclippedDepth }
}
export class Mesh {
/** @type {PrimitiveTopology} */
topology
/** @type {Map<VertexAttributeId, MeshAttributeData>} */
attributes = new Map()
/** @type {Indices} */
indices
}

View file

@ -0,0 +1,116 @@
// https://www.w3.org/TR/webgpu/#enumdef-gpuvertexformat
import { Union } from '/public/vendor/kojima/union.js'
import { capitalizen } from '/src/utils/index'
/** @typedef {Uint8ArrayConstructor | Int8ArrayConstructor | Uint16ArrayConstructor | Int16ArrayConstructor | Uint32ArrayConstructor | Int32ArrayConstructor | Float32ArrayConstructor | Float64ArrayConstructor} TypedArrayConstructor */
/** @type {GPUVertexFormat[]} */
export const GPUVertexFormats = ['uint8', 'uint8x2', 'uint8x4', 'sint8', 'sint8x2', 'sint8x4', 'unorm8', 'unorm8x2', 'unorm8x4', 'snorm8', 'snorm8x2', 'snorm8x4', 'uint16', 'uint16x2', 'uint16x4', 'sint16', 'sint16x2', 'sint16x4', 'unorm16', 'unorm16x2', 'unorm16x4', 'snorm16', 'snorm16x2', 'snorm16x4', 'float16', 'float16x2', 'float16x4', 'float32', 'float32x2', 'float32x3', 'float32x4', 'uint32', 'uint32x2', 'uint32x3', 'uint32x4', 'sint32', 'sint32x2', 'sint32x3', 'sint32x4', 'unorm10-10-10-2', 'unorm8x4-bgra']
export class MeshAttributeData {
#format
#buffer
/**
* @param {VertexFormat} format
* @param {ArrayBufferLike} buffer
*/
constructor(format, buffer) {
this.#format = format
this.#buffer = buffer
}
get format() { return this.#format }
get buffer() { return this.#buffer }
}
export class VertexFormat {
#name
#components
#size
#bufferType
/**
* @param {GPUVertexFormat} name
* @param {number} components
* @param {number} size
* @param {TypedArrayConstructor} bufferType
*/
constructor(name, components, size, bufferType) {
this.#name = name
this.#components = components
this.#size = size
this.#bufferType = bufferType
}
get name() { return this.#name }
get components() { return this.#components }
get size() { return this.#size }
get bufferType() { return this.#bufferType }
}
// INFO: ugly initialization code for VertexFormat statics
// this is for doing VertexFormat.Uint8 to get that format
(() => {
/** @type {[GPUVertexFormat, number, number, TypedArrayConstructor][]} */
const formats = [
['uint8', 1, 1, Uint8Array],
['uint8x2', 2, 2, Uint8Array],
['uint8x4', 4, 4, Uint8Array],
['sint8', 1, 1, Int8Array],
['sint8x2', 2, 2, Int8Array],
['sint8x4', 4, 4, Int8Array],
['unorm8', 1, 1, Uint8Array],
['unorm8x2', 2, 2, Uint8Array],
['unorm8x4', 4, 4, Uint8Array],
['snorm8', 1, 1, Int8Array],
['snorm8x2', 2, 2, Int8Array],
['snorm8x4', 4, 4, Int8Array],
['uint16', 1, 2, Uint16Array],
['uint16x2', 2, 4, Uint16Array],
['uint16x4', 4, 8, Uint16Array],
['sint16', 1, 2, Int16Array],
['sint16x2', 2, 4, Int16Array],
['sint16x4', 4, 8, Int16Array],
['unorm16', 1, 2, Uint16Array],
['unorm16x2', 2, 4, Uint16Array],
['unorm16x4', 4, 8, Uint16Array],
['snorm16', 1, 2, Int16Array],
['snorm16x2', 2, 4, Int16Array],
['snorm16x4', 4, 8, Int16Array],
['float16', 1, 2, Uint16Array],
['float16x2', 2, 4, Uint16Array],
['float16x4', 4, 8, Uint16Array],
['float32', 1, 4, Float32Array],
['float32x2', 2, 8, Float32Array],
['float32x3', 3, 12, Float32Array],
['float32x4', 4, 16, Float32Array],
['uint32', 1, 4, Uint32Array],
['uint32x2', 2, 8, Uint32Array],
['uint32x3', 3, 12, Uint32Array],
['uint32x4', 4, 16, Uint32Array],
['sint32', 1, 4, Int32Array],
['sint32x2', 2, 8, Int32Array],
['sint32x3', 3, 12, Int32Array],
['sint32x4', 4, 16, Int32Array],
['unorm10-10-10-2', 4, 4, Uint32Array],
['unorm8x4-bgra', 4, 4, Uint8Array]
]
/**
* @param {string} name
*/
const capitalize = name => name.charAt(0).toUpperCase() + name.slice(1)
formats.forEach(([name, components, size]) => {
Object.defineProperty(VertexFormat, capitalize(value.name), {
value: new VertexFormat(name, components, size),
writable: false,
configurable: false
})
})
})()

View file

@ -0,0 +1,213 @@
import { VertexFormat } from './vertex-format.js'
/**
* @typedef {number} MeshVertexAttributeId
* @typedef {number} BufferAddress
*/
export class VertexAttribute {
#format
#offset
#shaderLocation
/**
* @param {VertexFormat} format
* @param {number} offset
* @param {number} shaderLocation
*/
constructor(format, offset, shaderLocation) {
this.#format = format
this.#offset = offset
this.#shaderLocation = shaderLocation
}
get format() { return this.#format }
get offset() { return this.#offset }
get shaderLocation() { return this.#shaderLocation }
}
export class MeshVertexAttribute {
#name
#id
#format
/**
* @param {string} name
* @param {MeshVertexAttributeId} id
* @param {VertexFormat} format
*/
constructor(name, id, format) {
this.#name = name
this.#id = id
this.#format = format
}
get name() { return this.#name }
get id() { return this.#id }
get format() { return this.#format }
}
export class VertexAttributeDescriptor {
#id
#name
#shaderLocation
/**
* @param {MeshVertexAttributeId} id
* @param {string} name
* @param {number} location
*/
constructor(id, name, location) {
this.#id = id
this.#name = name
this.#shaderLocation = location
}
get id() { return this.#id }
get name() { return this.#name }
get shaderLocation() { return this.#shaderLocation }
}
class VertexAttributeError extends Error {
/**
* @param {MeshVertexAttributeId} id
* @param {string} name
* @param {string} message
*/
constructor(id, name, message) {
super(message)
this.id = id
this.name = name
}
/**
* @param {MeshVertexAttributeId} id
* @param {string} name
*/
static missing(id, name) {
return new VertexAttributeError(id, name, `Missing vertex attribute ${name} (id: ${id})`)
}
}
export class VertexBufferLayout {
#stride
#stepMode
#attributes
/**
* @param {BufferAddress} stride
* @param {GPUVertexStepMode} stepMode
* @param {VertexAttribute[]} attributes
*/
constructor(stride, stepMode, attributes) {
this.#stride = stride
this.#stepMode = stepMode
this.#attributes = attributes
}
get stride() { return this.#stride }
get stepMode() { return this.#stepMode }
get attributes() { return this.#attributes }
/**
* @param {GPUVertexStepMode} mode
* @param {VertexFormat[]} formats
*/
static fromVertexFormats(mode, formats) {
let offset = 0
const attributes = []
const len = formats.length
for (let i = 0; i < len; i++) {
const format = formats[i]
attributes.push(new VertexAttribute(
format,
offset,
i
))
offset += format.size
}
return new VertexBufferLayout(
offset,
mode,
attributes
)
}
}
export class MeshVertexBufferLayout {
#ids
#layout
/**
* @param {Set<MeshVertexAttributeId>} ids
* @param {VertexBufferLayout} layout
*/
constructor(ids, layout) {
this.#ids = ids
this.#layout = layout
}
get attributeIds() { return this.#ids }
get layout() { return this.#layout }
/**
* @param {MeshVertexAttributeId} id
*/
has(id) {
return this.#ids.has(id)
}
/**
* @param {VertexAttributeDescriptor[]} descriptors
*/
getLayout(descriptors) {
let attributes = []
const len = descriptors.length
for (let i = 0; i < len; i++) {
const desc = descriptors[i]
const index = descriptors.indexOf(desc)
if (index <= -1) {
throw VertexAttributeError.missing(desc.id, desc.name)
}
const attr = this.#layout.attributes[index]
attributes.push(new VertexAttribute(
attr.format,
attr.offset,
desc.shaderLocation
))
}
return new VertexBufferLayout(
this.#layout.stride,
this.#layout.stepMode,
attributes
)
}
}
// TODO: revisit this to either optimize the sparse array,
// model it more like Bevy's tagged union, or refactor
// it entirely to better suit JS
export class MeshAttributeData {
/** @type {MeshVertexAttribute} */
#attribute
/** @type {number[]} */
#values
/**
* @param {MeshVertexAttribute} attribute
* @param {number[]} values
*/
constructor(attribute, values) {
this.#attribute = attribute
this.#values = values
}
}

View file

@ -0,0 +1,9 @@
/**
* @param {World} world
*/
export const render = world => {
/** @param {World} world */
return world => {
}
}

View file

@ -0,0 +1,36 @@
export class WebGLContext {
/** @type {WebGL2RenderingContext} */
#context
/**
* @param {HTMLCanvasElement} canvas
*/
constructor(canvas) {
this.#context = canvas.getContext('webgl2')
if (!this.#context) {
throw new Error('WebGL2 is unsupported')
}
}
get context() {
return this.#context
}
get clearColor() {
return this.#context.getParameter(this.#context.COLOR_CLEAR_VALUE)
}
/**
* @param {[number, number, number, number]} value
*/
set clearColor(value) {
this.#context.clearColor(...value)
}
clear() {
}
}

View file

@ -0,0 +1,427 @@
export class WebGPUError extends Error {
/**
* @param {string} message
*/
constructor(message) {
super(message)
}
static unsupported() {
return new WebGPUError("WebGPU is unsupported in this browser")
}
static adapterUnavailable() {
return new WebGPUError("Could not request a WebGPU adapter.")
}
}
export class WebGPUBuilderError extends Error {
/**
* @param {string} message
*/
constructor(message) {
super(message)
}
/**
* @param {string} property
*/
static missing(property) {
return new WebGPUBuilderError(`Missing required property: ${property}`)
}
}
class GPUCanvasConfigurationBuilder {
/** @type {WebGPUContextBuilder} */
#contextBuilder
/** @type {GPUTextureUsageFlags} */
#usage = 0x10
/** @type {GPUTextureFormat[]} */
#viewFormats = []
/** @type {PredefinedColorSpace} */
#colorSpace = 'srgb'
/** @type {GPUCanvasToneMapping} */
#toneMapping = {}
/** @type {GPUCanvasAlphaMode} */
#alphaMode = 'opaque'
/**
* @param {WebGPUContextBuilder} builder
*/
constructor(builder) {
this.#contextBuilder = builder
}
/**
* @param {GPUTextureUsageFlags} usage
*/
usage(usage) {
this.#usage = usage
return this
}
/**
* @param {GPUTextureUsageFlags} usage
*/
addUsage(usage) {
this.#usage = this.#usage | usage
return this
}
/**
* @param {GPUTextureFormat[]} formats
*/
viewFormats(formats) {
this.#viewFormats = formats
return this
}
/**
* @param {GPUTextureFormat} format
*/
addViewFormat(format) {
this.#viewFormats.push(format)
return this
}
/**
* @param {PredefinedColorSpace} space
*/
colorSpace(space) {
this.#colorSpace = space
return this
}
/**
* @param {GPUCanvasToneMappingMode} mode
*/
toneMappingMode(mode) {
this.#toneMapping = { mode }
return this
}
/**
* @param {GPUCanvasAlphaMode} mode
*/
alphaMode(mode) {
this.#alphaMode = mode
return this
}
/**
* @returns {GPUCanvasBuilderConfiguration}
*/
build() {
return {
usage: this.#usage,
viewFormats: this.#viewFormats,
colorSpace: this.#colorSpace,
toneMapping: this.#toneMapping,
alphaMode: this.#alphaMode
}
}
/**
* @returns {WebGPUContextBuilder}
*/
apply() {
return this.#contextBuilder.configureCanvas(this.build())
}
}
/** @typedef {'core' | 'compatibility'} GPUFeatureLevel */
class GPUAdapterOptionsBuilder {
#contextBuilder
/** @type {GPUFeatureLevel} */
#featureLevel = 'core'
/** @type {GPUPowerPreference} */
#powerPreference
/** @type {boolean} */
#forceFallbackAdapter = false
/** @type {boolean} */
#xrCompatible = false
/**
* @param {WebGPUContextBuilder} builder
*/
constructor(builder) {
this.#contextBuilder = builder
}
/**
* @param {GPUFeatureLevel} featureLevel
*/
featureLevel(featureLevel) {
this.#featureLevel = featureLevel
return this
}
/**
* @param {GPUPowerPreference?} preference
*/
powerPreference(preference) {
this.#powerPreference = preference
return this
}
/**
* @param {boolean} force
*/
forceFallbackAdapter(force) {
this.#forceFallbackAdapter = force
return this
}
/**
* @param {boolean} compatible
*/
xrCompatible(compatible) {
this.#xrCompatible = compatible
return this
}
/**
* @returns {GPURequestAdapterOptions}
*/
build() {
return {
featureLevel: this.#featureLevel,
powerPreference: this.#powerPreference,
forceFallbackAdapter: this.#forceFallbackAdapter,
xrCompatible: this.#xrCompatible
}
}
apply() {
return this.#contextBuilder.adapter(this.build())
}
}
class GPUDeviceDescriptorBuilder {
/** @type {WebGPUContextBuilder} */
#contextBuilder
/** @type {GPUFeatureName[]} */
#features = []
/** @type {Record<string, GPUSize64?>} */
#limits = {}
/**
* @param {WebGPUContextBuilder} builder
*/
constructor(builder) {
this.#contextBuilder = builder
}
/**
* @param {GPUFeatureName[]} features
*/
requiredFeatures(features) {
this.#features = features
return this
}
/**
* @param {GPUFeatureName} feature
*/
addRequiredFeature(feature) {
this.#features.push(feature)
return this
}
/**
* @param {Record<string, GPUSize64?>} limits
*/
requiredLimits(limits) {
this.#limits = limits
return this
}
/**
* @param {string} key
* @param {GPUSize64?} value
*/
setRequiredLimit(key, value) {
this.#limits[key] = value
return this
}
/**
* @returns {GPUDeviceDescriptor}
*/
build() {
return {
requiredFeatures: this.#features,
requiredLimits: this.#limits
}
}
apply() {
return this.#contextBuilder.device(this.build())
}
}
/**
* @typedef {Object} GPUCanvasBuilderConfiguration
* @property {GPUTextureUsageFlags} [usage=0x10]
* @property {GPUTextureFormat[]} [viewFormats=[]]
* @property {PredefinedColorSpace} [colorSpace='srgb']
* @property {GPUCanvasToneMapping} [toneMapping = {}]
* @property {GPUCanvasAlphaMode} [alphaMode='opaque']
*/
export class WebGPUContextBuilder {
/** @type {Promise<GPUAdapter>} */
#adapter
/** @type {GPUCanvasBuilderConfiguration} */
#canvasConfig
/** @type {GPUCanvasContext} */
#context
/** @type {Promise<GPUDevice>} */
#device
/** @type {number} */
#dpr = window.devicePixelRatio || 1
/**
* @param {string} type
*/
#warnDefault(type) {
console.warn(`WARN: Requesting WebGPU ${type} with default options.`)
}
/**
* @param {GPURequestAdapterOptions} [descriptor]
* @returns {WebGPUContextBuilder}
* @throws {WebGPUError} Throws if WebGPU is not supported
*/
adapter(descriptor) {
if (!navigator.gpu) {
throw WebGPUError.unsupported()
}
this.#adapter = navigator.gpu.requestAdapter(descriptor)
return this
}
buildAdapterConfiguration() {
return new GPUAdapterOptionsBuilder(this)
}
/**
* @param {HTMLCanvasElement} canvas
* @returns {WebGPUContextBuilder}
*/
context(canvas) {
this.#context = canvas.getContext('webgpu')
return this
}
buildCanvasConfiguration() {
return new GPUCanvasConfigurationBuilder(this)
}
/**
* @param {GPUDeviceDescriptor} [descriptor]
* @returns {WebGPUContextBuilder}
* @throws {WebGPUError} Throws if WebGPU is not supported
*/
device(descriptor) {
if (!this.#adapter) {
this.#warnDefault('adapter')
this.adapter()
}
this.#device = this.#adapter.then(adapter => adapter.requestDevice(descriptor))
return this
}
buildDeviceDescriptor() {
return new GPUDeviceDescriptorBuilder(this)
}
/**
* @param {GPUCanvasBuilderConfiguration} [configuration]
* @returns {WebGPUContextBuilder}
*/
configureCanvas(configuration) {
this.#canvasConfig = configuration
return this
}
/**
* @returns {Promise<WebGPUContext>}
* @throws {WebGPUBuilderError} Throws if context is undefined or if WebGPU is unsupported
*/
async build() {
if (!this.#context) {
throw WebGPUBuilderError.missing('context')
}
if (!this.#device) {
this.#warnDefault('device')
this.device()
}
const [adapter, device] = await Promise.all([this.#adapter, this.#device])
const format = navigator.gpu.getPreferredCanvasFormat()
if (this.#canvasConfig) {
this.#context.configure({
device,
format,
...this.#canvasConfig
})
}
return new WebGPUContext(
this.#context,
adapter,
device,
format,
this.#dpr
)
}
}
export class WebGPUContext {
#adapter
#context
#device
#format
#dpr
/**
* @param {GPUCanvasContext} context
* @param {GPUAdapter} adapter
* @param {GPUDevice} device
* @param {GPUTextureFormat} format
* @param {number} [dpr]
*/
constructor(context, adapter, device, format, dpr) {
this.#context = context
this.#adapter = adapter
this.#device = device
this.#format = format
this.#dpr = dpr || window.devicePixelRatio || 1
}
get adapter() { return this.#adapter }
get context() { return this.#context }
get device() { return this.#device }
get queue() { return this.#device.queue }
get format() { return this.#format }
get devicePixelRatio() { return this.#dpr }
static create() {
return new WebGPUContextBuilder()
}
}

View file

@ -0,0 +1,15 @@
import { Matrix4x4 } from '../../../components/math.js'
import { createRelation } from '/src/ecs.js'
// Local transform, relative to parent
export const Transform = {
matrix: Matrix4x4()
}
export const GlobalTransform = {
matrix: Matrix4x4()
}
export const ChildOf = createRelation({ autoRemoveSubject: true, exclusive: true })

View file

@ -0,0 +1,8 @@
import { Dirty, Transform } from './components/index.js'
import { updateTransform } from './systems/index.js'
import { addComponent, observe, onSet } from '/src/ecs.js'
export const TransformPlugin = engine => {
console.log('hi from transform plugin!')
}

View file

@ -0,0 +1,13 @@
import { ChildOf, GlobalTransform, Transform } from '../components/index.js'
import { getRelationTargets, Not, query, Wildcard } from '/src/ecs.js'
import * as twgl from '/public/vendor/twgl/twgl-full.module.js'
import { World } from '/src/core/engine.js'
/** @param {World} _world */
export const updateTransform = _world => {
/** @param {World} world */
return world => {
// TODO: implement transform propagation with GlobalTransform and Transform
}
}

View file

@ -0,0 +1,10 @@
import { Engine, Startup } from '/src/core/engine.js'
import { resize } from './systems/index.js'
/**
* @param {Engine} engine
*/
export const WindowPlugin = engine => {
engine.addSystem(Startup, resize)
}

View file

@ -0,0 +1,99 @@
const ResizeObserverBox = Object.freeze({
ContentBox: 'content-box',
BorderBox: 'border-box',
DevicePixelContentBox: 'device-pixel-content-box',
})
export class ResizeHandler {
/** @type {HTMLCanvasElement} */
#canvas
/** @type {ResizeObserver} */
#observer
/**
* @typedef {(width: number, height: number) => void} Subscriber
* @type {Set<Subscriber>}
*/
#subscribers = new Set()
/**
* @param {HTMLCanvasElement} canvas
* @param {number} dpr
* @param {number} limit
*/
constructor(canvas, dpr, limit) {
this.#canvas = canvas
this.#observer = new ResizeObserver(entries => {
for (const entry of entries) {
/** @type {HTMLCanvasElement} */
// @ts-ignore
const canvas = entry.target
const size = this._getSize(entry)[0]
let width = size.inlineSize * dpr
let height = size.blockSize * dpr
width = this._clamp(width, 1, limit)
height = this._clamp(height, 1, limit)
if (width !== canvas.width || height !== canvas.height) {
canvas.width = width
canvas.height = height
this.#notify(width, height)
}
}
})
}
/**
* @param {Subscriber} fn
*/
subscribe(fn) {
this.#subscribers.add(fn)
}
/**
* @param {number} width
* @param {number} height
*/
#notify(width, height) {
this.#subscribers
.forEach(fn => fn(width, height))
}
/**
* @param {Subscriber} fn
*/
unsubscribe(fn) {
this.#subscribers.delete(fn)
}
start() {
this.#observer.observe(
this.#canvas,
{ box: ResizeObserverBox.DevicePixelContentBox }
)
}
stop() {
this.#observer.unobserve(this.#canvas)
}
/**
* @param {ResizeObserverEntry} entry
*/
_getSize(entry) {
return entry.devicePixelContentBoxSize || entry.contentBoxSize
}
/**
* @param {number} size
* @param {number} min
* @param {number} max
*/
_clamp(size, min, max) {
return Math.max(min, Math.min(size, max))
}
}

View file

@ -0,0 +1,21 @@
import { WebGPUContext } from '../../renderer/webgpu-context.js'
import { ResizeHandler } from '../resize.js'
import { World } from '/src/core/engine.js'
/**
* @param {World} world
*/
export const resize = world => {
const webgpu = world.getResource(WebGPUContext)
const resizer = new ResizeHandler(
// @ts-ignore
webgpu.context.canvas,
webgpu.devicePixelRatio,
webgpu.device.limits.maxTextureDimension2D
)
resizer.start()
world.addResource(resizer)
}

9
src/utils/error.js Normal file
View file

@ -0,0 +1,9 @@
export class NotImplementedError extends Error {
/**
* @param {string} message
*/
constructor(message) {
super(message)
}
}

28
src/utils/index.js Normal file
View file

@ -0,0 +1,28 @@
export const capitalizen = (n, str) => {
str.slice(0, n).toUpperCase() + str.slice(n)
}
/**
* @param {Object} a
* @param {Object} b
*/
export const assignOptions = (a, b) => {
const props = Object.entries(b)
.map(([key, value]) => [`#${key}`, value])
Object.assign(a, Object.fromEntries(props))
}
export const trampoline = (f, ...args) => {
let x = f(...args)
while (typeof x === 'function') {
x = x()
}
return x
}
export const isObject = value => {
return Object.getPrototypeOf(value) === Object.prototype
}

105
src/utils/ordered-map.js Normal file
View file

@ -0,0 +1,105 @@
/** @typedef {-1 | 0 | 1} Ternary */
/**
* @template T
* @typedef {(a: T, b: T) => Ternary | NaN} Comparer
*/
/**
* @template X, Y
* @param {X | Y} a
* @param {Y} b
* @returns [X | undefined, Y | undefined]
*/
const optionalSecond = (a, b) => {
let x = a
let y = b
if (!b) {
x = undefined
y = b
}
return [x, y]
}
/**
* @template K, V
*/
export class OrderedMap extends Map {
_sort
/**
* @overload
* @param {Iterable.<[K, V]>} iterable
* @param {Comparer<[K, V]>} comparer
*/
/**
* @overload
* @param {Comparer<[K, V]>} comparer
*/
/**
* @param {Iterable.<[K, V]> | Comparer<[K, V]>} iterable
* @param {Comparer<[K, V]>} [comparer]
*/
constructor(iterable, comparer) {
const [iter, sort] = optionalSecond(iterable, comparer)
/** @ts-ignore */
super(iter)
this._sort = sort
}
/**
* @param {Comparer<[K, V]>} comparer
*/
sortWith(comparer) {
this._sort = comparer
}
*[Symbol.iterator]() {
/** @ts-ignore */
yield* [...this.entries()].sort(this._sort)
}
}
/**
* @template T
* @param {(value: T) => number} get
* @returns {Comparer<T>}
*/
const prioritySort = get => (a, b) => get(a) - get(b)
/**
* @template K, V
*/
export class PriorityMap extends OrderedMap {
/**
* @typedef {(value: [K, V]) => number} PriorityGetter
*/
/** @type PriorityGetter */
_getter
/**
* @overload
* @param {Iterable.<[K, V]>} iterable
* @param {PriorityGetter} getter
*/
/**
* @overload
* @param {PriorityGetter} getter
*/
/**
* @param {Iterable.<[K, V]> | PriorityGetter} iterable
* @param {PriorityGetter} [getter]
*/
constructor(iterable, getter) {
const [iter, get] = optionalSecond(iterable, getter)
/** @ts-ignore */
super(iter, prioritySort(get))
/** @ts-ignore */
this._getter = get
}
}

9
src/utils/webgpu.js Normal file
View file

@ -0,0 +1,9 @@
export class Attribute {
#name
#format
#location
#buffer
#count
#components
}