Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions embed-perry/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
*.exe
*.o
dist/
49 changes: 49 additions & 0 deletions embed-perry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Bloom × Perry UI — embedded render view (issue #2395)

A normal Perry UI app that embeds the Bloom game engine as a live render view
(`BloomView`), the way the issue asks for — "a BloomView that renders a Bloom
scene directly inside a Perry UI app", similar to Flame inside Flutter.

![screenshot](bloomFull.png)

## How it works

`BloomView(width, height)` is a Perry UI widget that reserves a native child
window in the view tree. Perry UI does **not** link or know about Bloom — it
only owns the window and exposes its handle:

```ts
import { App, VStack, Text, BloomView, bloomViewGetHwnd } from 'perry/ui';
import { attachToHwnd, beginDrawing, endDrawing, clearBackground, /* … */ } from 'bloom';

const view = BloomView(820, 480);

let attached = 0;
setInterval(() => {
if (!attached) { attachToHwnd(bloomViewGetHwnd(view), 820, 480); attached = 1; }
beginDrawing();
clearBackground({ r: 18, g: 22, b: 34, a: 255 });
// …draw a 3D scene…
endDrawing();
}, 16);

App({ title: "Perry UI × Bloom", width: 880, height: 600,
body: VStack(10, [Text("…"), view]) });
```

User TypeScript hands the `BloomView`'s HWND to Bloom via `attachToHwnd`. Bloom
builds its wgpu (DX12/Vulkan) surface on that window, subclasses it for
resize/input, and renders into it. The host (Perry UI) owns the message loop, so
the app drives Bloom's frame loop itself (`beginDrawing` → draw → `endDrawing`)
from a `setInterval` tick — `runGame` is not used (it would block).

This keeps `perry-ui-windows` free of any Bloom dependency: apps that never call
`BloomView` pull in nothing extra.

## Build & run (Windows)

```bash
PERRY=/path/to/perry-with-bloomview/perry.exe ./build-windows.sh --run
```

Currently implemented on the Windows target.
Binary file added embed-perry/bloomFull.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions embed-perry/build-windows.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/bin/bash
# Build the "Bloom inside Perry UI" demo for Windows (issue #2395).
#
# Requires a Perry build that includes the `BloomView` widget (perry-ui-windows
# + the BloomView dispatch/manifest wiring). Point $PERRY at it, or have a
# matching `perry` on PATH.
set -e

PERRY="${PERRY:-perry}"

"$PERRY" compile --target windows src/main.ts -o BloomEmbed
mv -f BloomEmbed BloomEmbed.exe 2>/dev/null || true

# Cranelift doesn't emit __chkstk stack probes, so large functions can skip the
# guard page and crash. Pre-commit a 1MB stack (same fix the Bloom games use).
EDITBIN="/c/Program Files (x86)/Microsoft Visual Studio/18/BuildTools/VC/Tools/MSVC/14.50.35717/bin/Hostx64/x64/editbin.exe"
if [ -f "$EDITBIN" ]; then
"$EDITBIN" /STACK:67108864,1048576 BloomEmbed.exe
else
echo "Warning: editbin not found; skipping stack fix. App may crash on launch."
fi

echo "Built BloomEmbed.exe"
if [ "$1" = "--run" ]; then
cmd //c "$(cygpath -w "$(pwd)/BloomEmbed.exe")"
fi
14 changes: 14 additions & 0 deletions embed-perry/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "bloom-embed-perry",
"version": "1.0.0",
"description": "Demo: the Bloom engine rendering inside a normal Perry UI app (issue #2395)",
"main": "src/main.ts",
"dependencies": {
"bloom": "file:../engine/"
},
"perry": {
"allow": {
"nativeLibrary": ["bloom"]
}
}
}
85 changes: 85 additions & 0 deletions embed-perry/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Bloom engine embedded inside a normal Perry UI app — issue #2395.
//
// The window chrome (title label, vertical stack) is plain Perry UI. The large
// viewport is a `BloomView`: Perry UI reserves a native child window and hands
// its HWND to the Bloom engine via `attachToHwnd`. From then on Bloom owns that
// surface and we drive a 3D scene every tick, while the surrounding Perry UI
// stays fully interactive.

import { App, VStack, Text, BloomView, bloomViewGetHwnd } from 'perry/ui';
import {
attachToHwnd,
beginDrawing, endDrawing, clearBackground,
beginMode3D, endMode3D,
drawCube, drawSphere,
setAmbientLight, setDirectionalLight,
getTime, vec3,
} from 'bloom';

const VIEW_W = 820;
const VIEW_H = 480;

// Perry UI reserves the child window; Bloom renders into it.
const view = BloomView(VIEW_W, VIEW_H);

// Drive Bloom's frame loop from the Perry UI run loop. We attach lazily on the
// first tick: by then App() has shown the window and laid the BloomView child
// out at its final size and parent, so Bloom builds its surface on a stable,
// visible window.
let attached = 0;
setInterval(() => {
if (attached === 0) {
attachToHwnd(bloomViewGetHwnd(view), VIEW_W, VIEW_H);
setAmbientLight({ r: 120, g: 140, b: 180, a: 255 }, 0.40);
setDirectionalLight(vec3(0.6, 0.9, 0.4), { r: 255, g: 235, b: 205, a: 255 }, 0.85);
attached = 1;
}

const t = getTime();
const cx = Math.cos(t * 0.6) * 9.0;
const cz = Math.sin(t * 0.6) * 9.0;

beginDrawing();
clearBackground({ r: 18, g: 22, b: 34, a: 255 });

beginMode3D({
position: vec3(cx, 6.0, cz),
target: vec3(0, 0.5, 0),
up: vec3(0, 1, 0),
fovy: 45.0,
projection: 0.0,
});

// Ground plane.
drawCube(vec3(0, -0.6, 0), 40.0, 0.4, 40.0, { r: 40, g: 50, b: 70, a: 255 });

// A spinning ring of cubes around a glowing core.
const N = 8;
for (let i = 0; i < N; i++) {
const a = (i / N) * Math.PI * 2.0 + t;
const x = Math.cos(a) * 4.0;
const z = Math.sin(a) * 4.0;
const h = 1.2 + Math.sin(t * 2.0 + i) * 0.6;
drawCube(
vec3(x, h * 0.5, z),
0.9, h, 0.9,
{ r: 120 + i * 14, g: 200 - i * 10, b: 240, a: 255 },
);
}

drawSphere(vec3(0, 1.4, 0), 1.1, { r: 255, g: 210, b: 120, a: 255 });
drawSphere(vec3(0, 1.4, 0), 1.6, { r: 255, g: 180, b: 90, a: 60 });

endMode3D();
endDrawing();
}, 16);

App({
title: "Perry UI × Bloom",
width: 880,
height: 600,
body: VStack(10, [
Text("🌸 Bloom engine rendering inside a Perry UI app — issue #2395"),
view,
]),
});
149 changes: 139 additions & 10 deletions native/windows/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ use std::sync::OnceLock;

static mut ENGINE: OnceLock<EngineState> = OnceLock::new();

/// True when the engine renders into a host-provided child window (a Perry UI
/// `BloomView`) rather than a Bloom-owned top-level window. In embedded mode
/// the host owns the Win32 message loop, so `bloom_begin_drawing` must not pump
/// messages itself and `bloom_window_should_close` always reports "stay open".
#[cfg(windows)]
static mut EMBEDDED: bool = false;

fn engine() -> &'static mut EngineState {
unsafe { ENGINE.get_mut().expect("Engine not initialized") }
}
Expand Down Expand Up @@ -298,6 +305,68 @@ mod win32 {
}
}
}

// ---- Embedded mode (Perry UI BloomView host window) ----
//
// When Bloom renders into a Perry UI child window, that window already has
// Perry's own WNDPROC. We classic-subclass it so Bloom sees the WM_SIZE /
// keyboard / mouse it needs (its own `wndproc` above never runs for a
// foreign-class window), then chain to the original proc. The host's
// message loop dispatches these — Bloom never pumps in embedded mode.
static mut EMBED_ORIG_WNDPROC: isize = 0;

unsafe extern "system" fn embedded_wndproc(
hwnd: HWND,
msg: u32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
match msg {
0x0005 /* WM_SIZE */ => {
let phys_w = (lparam.0 & 0xFFFF) as u32;
let phys_h = ((lparam.0 >> 16) & 0xFFFF) as u32;
if phys_w > 0 && phys_h > 0 {
if let Some(eng) = ENGINE.get_mut() {
if phys_w != eng.renderer.physical_width()
|| phys_h != eng.renderer.physical_height()
{
let scale = dpi_scale(hwnd);
let log_w = ((phys_w as f64) / scale).round() as u32;
let log_h = ((phys_h as f64) / scale).round() as u32;
eng.renderer.resize(phys_w, phys_h, log_w, log_h);
}
}
}
}
WM_KEYDOWN => {
let k = map_keycode(wparam.0 as u32);
if k > 0 { if let Some(eng) = ENGINE.get_mut() { eng.input.set_key_down(k); } }
}
WM_KEYUP => {
let k = map_keycode(wparam.0 as u32);
if k > 0 { if let Some(eng) = ENGINE.get_mut() { eng.input.set_key_up(k); } }
}
WM_MOUSEMOVE => {
let x = (lparam.0 & 0xFFFF) as i16 as f64;
let y = ((lparam.0 >> 16) & 0xFFFF) as i16 as f64;
if let Some(eng) = ENGINE.get_mut() { eng.input.set_mouse_position(x, y); }
}
WM_LBUTTONDOWN => { if let Some(eng) = ENGINE.get_mut() { eng.input.set_mouse_button_down(0); } }
WM_LBUTTONUP => { if let Some(eng) = ENGINE.get_mut() { eng.input.set_mouse_button_up(0); } }
WM_RBUTTONDOWN => { if let Some(eng) = ENGINE.get_mut() { eng.input.set_mouse_button_down(1); } }
WM_RBUTTONUP => { if let Some(eng) = ENGINE.get_mut() { eng.input.set_mouse_button_up(1); } }
_ => {}
}
let orig: WNDPROC = std::mem::transmute(EMBED_ORIG_WNDPROC);
CallWindowProcW(orig, hwnd, msg, wparam, lparam)
}

/// Subclass a host-provided child window so Bloom receives resize/input.
pub unsafe fn attach_subclass(hwnd: HWND) {
let prev = SetWindowLongPtrW(hwnd, GWLP_WNDPROC, embedded_wndproc as usize as isize);
EMBED_ORIG_WNDPROC = prev;
HWND_GLOBAL = Some(hwnd);
}
}

#[no_mangle]
Expand All @@ -307,7 +376,31 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u
#[cfg(windows)]
{
let (hwnd, phys_w, phys_h) = win32::create_window(width, height, title);
unsafe { init_engine_for_hwnd(hwnd, width as u32, height as u32, phys_w, phys_h); }
if fullscreen != 0.0 {
win32::set_fullscreen(true);
}
}

#[cfg(not(windows))]
{
let _ = (width, height, title, fullscreen);
panic!("bloom-windows can only run on Windows");
}
}

/// Build the wgpu surface + engine on an existing HWND (top-level window or
/// host-provided child). Shared by `bloom_init_window` (Bloom owns the window)
/// and `bloom_attach_hwnd` (a Perry UI `BloomView` child window). `logical_*`
/// are DPI-independent sizes; `phys_*` are the surface's physical client size.
#[cfg(windows)]
unsafe fn init_engine_for_hwnd(
hwnd: windows::Win32::Foundation::HWND,
logical_w: u32,
logical_h: u32,
phys_w: u32,
phys_h: u32,
) {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::DX12 | wgpu::Backends::VULKAN,
..wgpu::InstanceDescriptor::new_without_display_handle()
Expand Down Expand Up @@ -396,25 +489,58 @@ pub extern "C" fn bloom_init_window(width: f64, height: f64, title_ptr: *const u
};
surface.configure(&device, &surface_config);

let renderer = Renderer::new(device, queue, surface, surface_config, width as u32, height as u32);
unsafe { let _ = ENGINE.set(EngineState::new(renderer)); }
let renderer = Renderer::new(device, queue, surface, surface_config, logical_w, logical_h);
let _ = ENGINE.set(EngineState::new(renderer));
}

if fullscreen != 0.0 {
win32::set_fullscreen(true);
#[no_mangle]
pub extern "C" fn bloom_close_window() {}

/// Attach the engine to a host-provided child window (a Perry UI `BloomView`).
/// `hwnd_bits` is the raw HWND value as an integer (from `bloomViewGetHwnd`).
/// `width`/`height` are the logical viewport size. The engine builds its wgpu
/// surface on this window and subclasses it for resize/input; the host drives
/// frames via `bloom_begin_drawing` / `bloom_end_drawing`.
#[no_mangle]
pub extern "C" fn bloom_attach_hwnd(hwnd_bits: f64, width: f64, height: f64) {
#[cfg(windows)]
unsafe {
use windows::Win32::Foundation::{HWND, RECT};
use windows::Win32::UI::WindowsAndMessaging::GetClientRect;
let hwnd = HWND(hwnd_bits as i64 as isize as *mut core::ffi::c_void);
let mut rect = RECT::default();
let _ = GetClientRect(hwnd, &mut rect);
let phys_w = (rect.right - rect.left).max(1) as u32;
let phys_h = (rect.bottom - rect.top).max(1) as u32;
if ENGINE.get().is_none() {
init_engine_for_hwnd(hwnd, width as u32, height as u32, phys_w, phys_h);
}
EMBEDDED = true;
win32::attach_subclass(hwnd);
}

#[cfg(not(windows))]
{
panic!("bloom-windows can only run on Windows");
}
{ let _ = (hwnd_bits, width, height); }
}

/// Resize the engine's surface. `phys_*` are physical pixels, `log_*` logical.
/// Embedded `BloomView`s resize automatically via the subclassed WM_SIZE; this
/// is exposed for hosts that need to drive the size explicitly.
#[no_mangle]
pub extern "C" fn bloom_close_window() {}
pub extern "C" fn bloom_resize(phys_w: f64, phys_h: f64, log_w: f64, log_h: f64) {
#[cfg(windows)]
unsafe {
if let Some(eng) = ENGINE.get_mut() {
eng.renderer.resize(phys_w as u32, phys_h as u32, log_w as u32, log_h as u32);
}
}
#[cfg(not(windows))]
{ let _ = (phys_w, phys_h, log_w, log_h); }
}

#[no_mangle]
pub extern "C" fn bloom_window_should_close() -> f64 {
#[cfg(windows)]
unsafe { if EMBEDDED { return 0.0; } }
if engine().should_close { 1.0 } else { 0.0 }
}

Expand Down Expand Up @@ -475,7 +601,10 @@ fn poll_xinput_gamepad() {
pub extern "C" fn bloom_begin_drawing() {
#[cfg(windows)]
{
win32::poll_events();
// In embedded mode the host (Perry UI) owns the message loop and
// dispatches our subclassed window's messages — pumping here would
// steal messages from the host. Only poll when Bloom owns the window.
unsafe { if !EMBEDDED { win32::poll_events(); } }
poll_xinput_gamepad();
}
engine().begin_frame();
Expand Down
Loading
Loading