Skip to content

Commit 9d335a8

Browse files
Add OffscreenCanvas example
1 parent cfa1b2d commit 9d335a8

File tree

9 files changed

+463
-0
lines changed

9 files changed

+463
-0
lines changed

Examples/OffscrenCanvas/.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
xcuserdata/
5+
DerivedData/
6+
.swiftpm/configuration/registries.json
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.netrc

Examples/OffscrenCanvas/Package.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// swift-tools-version: 5.10
2+
3+
import PackageDescription
4+
5+
let package = Package(
6+
name: "Example",
7+
platforms: [.macOS("15"), .iOS("18"), .watchOS("11"), .tvOS("18"), .visionOS("2")],
8+
dependencies: [
9+
.package(path: "../../"),
10+
],
11+
targets: [
12+
.executableTarget(
13+
name: "MyApp",
14+
dependencies: [
15+
.product(name: "JavaScriptKit", package: "JavaScriptKit"),
16+
.product(name: "JavaScriptEventLoop", package: "JavaScriptKit"),
17+
]
18+
),
19+
]
20+
)

Examples/OffscrenCanvas/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# OffscreenCanvas example
2+
3+
Install Development Snapshot toolchain `DEVELOPMENT-SNAPSHOT-2024-07-08-a` or later from [swift.org/install](https://www.swift.org/install/) and run the following commands:
4+
5+
```sh
6+
$ (
7+
set -eo pipefail; \
8+
V="$(swiftc --version | head -n1)"; \
9+
TAG="$(curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/tag-by-version.json" | jq -e -r --arg v "$V" '.[$v] | .[-1]')"; \
10+
curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/builds/$TAG.json" | \
11+
jq -r '.["swift-sdks"]["wasm32-unknown-wasip1-threads"] | "swift sdk install \"\(.url)\" --checksum \"\(.checksum)\""' | sh -x
12+
)
13+
$ export SWIFT_SDK_ID=$(
14+
V="$(swiftc --version | head -n1)"; \
15+
TAG="$(curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/tag-by-version.json" | jq -e -r --arg v "$V" '.[$v] | .[-1]')"; \
16+
curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/builds/$TAG.json" | \
17+
jq -r '.["swift-sdks"]["wasm32-unknown-wasip1-threads"]["id"]'
18+
)
19+
$ ./build.sh
20+
$ npx serve
21+
```
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../Multithreading/Sources/JavaScript
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import JavaScriptEventLoop
2+
import JavaScriptKit
3+
4+
JavaScriptEventLoop.installGlobalExecutor()
5+
WebWorkerTaskExecutor.installGlobalExecutor()
6+
7+
protocol CanvasRenderer {
8+
func render(canvas: JSObject, size: Int) async throws
9+
}
10+
11+
struct BackgroundRenderer: CanvasRenderer {
12+
func render(canvas: JSObject, size: Int) async throws {
13+
let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1)
14+
let transferringCanvas = JSObject.transfer(canvas)
15+
let renderingTask = Task(executorPreference: executor) {
16+
let canvas = try await JSObject.receive(transferringCanvas)
17+
try await renderAnimation(canvas: canvas, size: size)
18+
}
19+
await withTaskCancellationHandler {
20+
try? await renderingTask.value
21+
} onCancel: {
22+
renderingTask.cancel()
23+
}
24+
executor.terminate()
25+
}
26+
}
27+
28+
struct MainThreadRenderer: CanvasRenderer {
29+
func render(canvas: JSObject, size: Int) async throws {
30+
try await renderAnimation(canvas: canvas, size: size)
31+
}
32+
}
33+
34+
// FPS Counter for CSS animation
35+
func startFPSMonitor() {
36+
let fpsCounterElement = JSObject.global.document.getElementById("fps-counter").object!
37+
38+
var lastTime = JSObject.global.performance.now().number!
39+
var frames = 0
40+
41+
// Create a frame counter function
42+
func countFrame() {
43+
frames += 1
44+
let currentTime = JSObject.global.performance.now().number!
45+
let elapsed = currentTime - lastTime
46+
47+
if elapsed >= 1000 {
48+
let fps = Int(Double(frames) * 1000 / elapsed)
49+
fpsCounterElement.textContent = .string("FPS: \(fps)")
50+
frames = 0
51+
lastTime = currentTime
52+
}
53+
54+
// Request next frame
55+
_ = JSObject.global.requestAnimationFrame!(
56+
JSClosure { _ in
57+
countFrame()
58+
return .undefined
59+
})
60+
}
61+
62+
// Start counting
63+
countFrame()
64+
}
65+
66+
@MainActor
67+
func onClick(renderer: CanvasRenderer) async throws {
68+
let document = JSObject.global.document
69+
70+
let canvasContainerElement = document.getElementById("canvas-container").object!
71+
72+
// Remove all child elements from the canvas container
73+
for i in 0..<Int(canvasContainerElement.children.length.number!) {
74+
let child = canvasContainerElement.children[i]
75+
_ = canvasContainerElement.removeChild!(child)
76+
}
77+
78+
let canvasElement = document.createElement("canvas").object!
79+
_ = canvasContainerElement.appendChild!(canvasElement)
80+
81+
let size = 800
82+
canvasElement.width = .number(Double(size))
83+
canvasElement.height = .number(Double(size))
84+
85+
let offscreenCanvas = canvasElement.transferControlToOffscreen!().object!
86+
try await renderer.render(canvas: offscreenCanvas, size: size)
87+
}
88+
89+
func main() async throws {
90+
let renderButtonElement = JSObject.global.document.getElementById("render-button").object!
91+
let cancelButtonElement = JSObject.global.document.getElementById("cancel-button").object!
92+
let rendererSelectElement = JSObject.global.document.getElementById("renderer-select").object!
93+
94+
var renderingTask: Task<Void, Error>? = nil
95+
96+
// Start the FPS monitor for CSS animations
97+
startFPSMonitor()
98+
99+
_ = renderButtonElement.addEventListener!(
100+
"click",
101+
JSClosure { _ in
102+
renderingTask?.cancel()
103+
renderingTask = Task {
104+
let selectedValue = rendererSelectElement.value.string!
105+
let renderer: CanvasRenderer =
106+
selectedValue == "main" ? MainThreadRenderer() : BackgroundRenderer()
107+
try await onClick(renderer: renderer)
108+
}
109+
return JSValue.undefined
110+
})
111+
112+
_ = cancelButtonElement.addEventListener!(
113+
"click",
114+
JSClosure { _ in
115+
renderingTask?.cancel()
116+
return JSValue.undefined
117+
})
118+
}
119+
120+
Task {
121+
try await main()
122+
}
123+
124+
#if canImport(wasi_pthread)
125+
import wasi_pthread
126+
import WASILibc
127+
128+
/// Trick to avoid blocking the main thread. pthread_mutex_lock function is used by
129+
/// the Swift concurrency runtime.
130+
@_cdecl("pthread_mutex_lock")
131+
func pthread_mutex_lock(_ mutex: UnsafeMutablePointer<pthread_mutex_t>) -> Int32 {
132+
// DO NOT BLOCK MAIN THREAD
133+
var ret: Int32
134+
repeat {
135+
ret = pthread_mutex_trylock(mutex)
136+
} while ret == EBUSY
137+
return ret
138+
}
139+
#endif
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import Foundation
2+
import JavaScriptKit
3+
4+
func sleepOnThread(milliseconds: Int, isolation: isolated (any Actor)? = #isolation) async {
5+
// Use the JavaScript setTimeout function to avoid hopping back to the main thread
6+
await withCheckedContinuation(isolation: isolation) { continuation in
7+
_ = JSObject.global.setTimeout!(
8+
JSOneshotClosure { _ in
9+
continuation.resume()
10+
return JSValue.undefined
11+
}, milliseconds
12+
)
13+
}
14+
}
15+
16+
func renderAnimation(canvas: JSObject, size: Int, isolation: isolated (any Actor)? = #isolation)
17+
async throws
18+
{
19+
let ctx = canvas.getContext!("2d").object!
20+
21+
// Animation state variables
22+
var time: Double = 0
23+
24+
// Create a large number of particles
25+
let particleCount = 5000
26+
var particles: [[Double]] = []
27+
28+
// Initialize particles with random positions and velocities
29+
for _ in 0..<particleCount {
30+
// [x, y, vx, vy, size, hue, lifespan, maxLife]
31+
let x = Double.random(in: 0..<Double(size))
32+
let y = Double.random(in: 0..<Double(size))
33+
let speed = Double.random(in: 0.2...2.0)
34+
let angle = Double.random(in: 0..<(2 * Double.pi))
35+
let vx = cos(angle) * speed
36+
let vy = sin(angle) * speed
37+
let particleSize = Double.random(in: 1.0...3.0)
38+
let hue = Double.random(in: 0..<360)
39+
let maxLife = Double.random(in: 100...300)
40+
particles.append([x, y, vx, vy, particleSize, hue, maxLife, maxLife])
41+
}
42+
43+
// Create emitter positions that will generate new particles
44+
let emitters = 5
45+
var emitterPositions: [[Double]] = []
46+
for i in 0..<emitters {
47+
let angle = Double(i) * 2 * Double.pi / Double(emitters)
48+
let distance = Double(size) * 0.3
49+
let x = Double(size) / 2 + cos(angle) * distance
50+
let y = Double(size) / 2 + sin(angle) * distance
51+
emitterPositions.append([x, y])
52+
}
53+
54+
while !Task.isCancelled {
55+
// Semi-transparent background for trail effect
56+
_ = ctx.fillStyle = .string("rgba(0, 0, 0, 0.05)")
57+
_ = ctx.fillRect!(0, 0, size, size)
58+
59+
// Intentionally add a computationally expensive calculation for main thread demonstration
60+
var expensiveCalculation = 0.0
61+
for _ in 0..<500 {
62+
expensiveCalculation += sin(time) * cos(time)
63+
}
64+
65+
// Update and render all particles
66+
for i in 0..<particles.count {
67+
// Update position
68+
particles[i][0] += particles[i][2]
69+
particles[i][1] += particles[i][3]
70+
71+
// Apply slight gravity
72+
particles[i][3] += 0.02
73+
74+
// Decrease lifespan
75+
particles[i][6] -= 1
76+
77+
// If particle is dead, respawn it from an emitter
78+
if particles[i][6] <= 0 {
79+
let emitterIndex = Int.random(in: 0..<emitterPositions.count)
80+
particles[i][0] = emitterPositions[emitterIndex][0]
81+
particles[i][1] = emitterPositions[emitterIndex][1]
82+
83+
let speed = Double.random(in: 0.5...3.0)
84+
let angle = Double.random(in: 0..<(2 * Double.pi))
85+
particles[i][2] = cos(angle) * speed
86+
particles[i][3] = sin(angle) * speed
87+
88+
particles[i][4] = Double.random(in: 1.0...3.0) // Size
89+
particles[i][5] = Double.random(in: 0..<360) // Hue
90+
particles[i][6] = particles[i][7] // Reset lifespan
91+
}
92+
93+
// Bounce off edges
94+
if particles[i][0] < 0 || particles[i][0] > Double(size) {
95+
particles[i][2] *= -0.8
96+
}
97+
if particles[i][1] < 0 || particles[i][1] > Double(size) {
98+
particles[i][3] *= -0.8
99+
}
100+
101+
// Calculate opacity based on lifespan
102+
let opacity = particles[i][6] / particles[i][7]
103+
104+
// Get coordinates and properties
105+
let x = particles[i][0]
106+
let y = particles[i][1]
107+
let size = particles[i][4]
108+
let hue = (particles[i][5] + time * 10).truncatingRemainder(dividingBy: 360)
109+
110+
// Draw particle
111+
_ = ctx.beginPath!()
112+
ctx.fillStyle = .string("hsla(\(hue), 100%, 60%, \(opacity))")
113+
_ = ctx.arc!(x, y, size, 0, 2 * Double.pi)
114+
_ = ctx.fill!()
115+
116+
// Connect nearby particles with lines (only check some to save CPU)
117+
if i % 20 == 0 {
118+
for j in (i + 1)..<min(i + 20, particles.count) {
119+
let dx = particles[j][0] - x
120+
let dy = particles[j][1] - y
121+
let dist = sqrt(dx * dx + dy * dy)
122+
123+
if dist < 30 {
124+
_ = ctx.beginPath!()
125+
ctx.strokeStyle = .string("rgba(255, 255, 255, \(0.1 * opacity))")
126+
ctx.lineWidth = .number(0.3)
127+
_ = ctx.moveTo!(x, y)
128+
_ = ctx.lineTo!(particles[j][0], particles[j][1])
129+
_ = ctx.stroke!()
130+
}
131+
}
132+
}
133+
}
134+
135+
// Draw emitters as glowing circles
136+
for i in 0..<emitterPositions.count {
137+
let x = emitterPositions[i][0]
138+
let y = emitterPositions[i][1]
139+
140+
// Emitter pulse effect
141+
let pulseSize = 10 + 5 * sin(time * 2 + Double(i))
142+
let hue = (time * 50 + Double(i) * 72).truncatingRemainder(dividingBy: 360)
143+
144+
// Draw glow
145+
let gradient = ctx.createRadialGradient!(x, y, 0, x, y, pulseSize * 2).object!
146+
_ = gradient.addColorStop!(0, "hsla(\(hue), 100%, 70%, 0.8)")
147+
_ = gradient.addColorStop!(1, "hsla(\(hue), 100%, 50%, 0)")
148+
149+
_ = ctx.beginPath!()
150+
ctx.fillStyle = .object(gradient)
151+
_ = ctx.arc!(x, y, pulseSize * 2, 0, 2 * Double.pi)
152+
_ = ctx.fill!()
153+
154+
// Center of emitter
155+
_ = ctx.beginPath!()
156+
ctx.fillStyle = .string("hsla(\(hue), 100%, 70%, 0.8)")
157+
_ = ctx.arc!(x, y, pulseSize * 0.5, 0, 2 * Double.pi)
158+
_ = ctx.fill!()
159+
}
160+
161+
// Update time and emitter positions
162+
time += 0.03
163+
164+
// Move emitters in circular patterns
165+
for i in 0..<emitterPositions.count {
166+
let angle = time * 0.2 + Double(i) * 2 * Double.pi / Double(emitters)
167+
let distance = Double(size) * 0.3 + sin(time * 0.5) * Double(size) * 0.05
168+
emitterPositions[i][0] = Double(size) / 2 + cos(angle) * distance
169+
emitterPositions[i][1] = Double(size) / 2 + sin(angle) * distance
170+
}
171+
172+
await sleepOnThread(milliseconds: 16, isolation: isolation)
173+
}
174+
}

Examples/OffscrenCanvas/build.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
swift build --swift-sdk "${SWIFT_SDK_ID:-wasm32-unknown-wasip1-threads}" -Xswiftc -Xclang-linker -Xswiftc -mexec-model=reactor -Xlinker --export=__main_argc_argv -c release -Xswiftc -g

0 commit comments

Comments
 (0)