DrSensor / nusa

incremental runtime that bring both simplicity and power into webdev (buildless, cross-language, data-driven)
MIT License
4 stars 0 forks source link

ECS #13

Closed DrSensor closed 1 year ago

DrSensor commented 2 years ago

This ECS feature are more like my toy experiment rather than serious one. Although no one ask for this šŸ˜‚, code composition for multiple logics that constantly in a loop while updating the retained GUI (DOM) are damn so hard.

Note ECS usages are not limited to gamedev or logic that constantly in a loop. ECS is a composition pattern that specifically optimized for doing mass control.

DrSensor commented 1 year ago

For now I only need query all value in multiple instance. For example,

<render-scope>
  <link as="script" href="position.js" data-persist>
  <input type="range" min="0" max="100" value="10" value:="x">
</render-scope>

...long content

<render-scope>
  <link as="script" href="position.js" data-persist>
  <input type="range" min="0" max="100" value="20" value:="x">
</render-scope>
import { u8 } from "wiles/types"
import { all, count, spawn } from "wiles/std"

class Position {
  @spawn(100, u8) // cap it to 100 instance **across pages**
  accessor x: number
}

// let's say I use this function in a widget/island build using Preact
const sumMin10 = () => {
  let total = 0
  for (let i = 0, x = all(Position).x; i < count(Position); i++)
    if (x[i] >= 10) total += x[i]
  return total
}

export { default as Position, sumLess10 }

This scenario doesn't require managing entity id via SparseSet or BitSet. The accessor just proxy to an item in UInt8Array. If size not specified, it will default to Array with dynamic capacity. For example,

import { all, spawn } from "wiles/std"

class Position {
  @spawn // no cap, grow as needed
  accessor x: number
}

// let's say I use this function in a widget/island build using Preact
const sumMin10 = () => {
  let total = 0 // simpler query since there is no cap (.length === number of instances)
  for (const x of all(Position).x) if (x >= 10) total += x
  return total
}

export { default as Position, sumLess10 }

All examples above are realtime! It changes for-loop behavior when there is a new instance or the value of 'x' is changed. To make it deterministic, you need to copy both count and the backed array. For example,

const sumMin10 = () => {
  let total = 0
  const // copy both count and backed array
    countPosition = count(Position),
    x = Object.getPrototypeOf(all(Position).x).constructor.from(all(Position).x) // or simply do UInt8Array.from(all(Position).x)
  for (let i = 0; i < countPosition; i++)
    if (x[i] >= 10) total += x[i]
  return total
}

But most of the time it really doesn't matter since Javascript is a single threaded language.

šŸŽ¶ "Never Gonna Give You Up" by Rick Ashley

DrSensor commented 1 year ago

I think I solve how to share data across spectrum (worker, wasm, shader). Basically spawn decorator need to know which buffer to use. For example,

// assume that WorldBuffer can be from 3rd-party lib

type AnyBufferConstructor = ArrayBufferConstructor | SharedArrayBufferConstructor

const init = <T extends AnyBufferConstructor>(
  Buffer: T,
  capacity?: number,
) => class Single {
  static #buffer: InstanceType<T>
  static get buffer() {
    return this.#buffer
  }
  offset: number
  constructor(
    length: number | 100,
    type: TypedArrayConstructor | UInt8ArrayConstructor,
  ) {
    Single.#buffer ??= new Buffer(capacity ?? length)
    this.offset = type.BYTES_PER_ELEMENT * length
  }
}

import { u8 } from "wiles/type"
import { spawn } from "wiles/std"

export default class {
  @spawn(100, u8, WorldBuffer)
  x: number
}

const WorldBuffer = init(ArrayBuffer, 500) // limit to 5 instances

Now if we want share it with worker thread, we need to tell it to use SharedArrayBuffer instead of ArrayBuffer.

const WorldBuffer = init(SharedArrayBuffer, 500)
new Worker(
  "sumMin10.js",
  { type: "module" },
).postMessage(WorldBuffer.buffer)
DrSensor commented 1 year ago

@frameloop decorator

Basically it just wrap method in requestAnimationFrame and some logic to make the system in loop deterministic and suspend/resume-able.

import { u16 } from "wiles/types"
import { frameloop, spawn, all, count } from "wiles/std"

export default class {
  @spawn(200, u16)
  accessor x // infer value

  @frameloop.once // explicit trigger but only once (i.e <button on:click="activateSinMove">)
  activateSinMove() {
    const { dt } = frameloop
    const { x } = all(this)
    for (let i = count(this); i--;) {
      x[i] = Math.sin(x[i])
    }
    // automatically update all x when this method end
  }
}

šŸ¤” TODO: explain how to pause/suspend and resume the frameloop (especially async one), especially when there is a lot of frameloop function running

DrSensor commented 1 year ago

Moved to #55 #56