diff --git a/embed-perry/.gitignore b/embed-perry/.gitignore new file mode 100644 index 0000000..5fe4327 --- /dev/null +++ b/embed-perry/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.exe +*.o +dist/ diff --git a/embed-perry/README.md b/embed-perry/README.md new file mode 100644 index 0000000..b62a53b --- /dev/null +++ b/embed-perry/README.md @@ -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. diff --git a/embed-perry/bloomFull.png b/embed-perry/bloomFull.png new file mode 100644 index 0000000..6acd26c Binary files /dev/null and b/embed-perry/bloomFull.png differ diff --git a/embed-perry/build-windows.sh b/embed-perry/build-windows.sh new file mode 100644 index 0000000..e884b79 --- /dev/null +++ b/embed-perry/build-windows.sh @@ -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 diff --git a/embed-perry/package.json b/embed-perry/package.json new file mode 100644 index 0000000..716700d --- /dev/null +++ b/embed-perry/package.json @@ -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"] + } + } +} diff --git a/embed-perry/src/main.ts b/embed-perry/src/main.ts new file mode 100644 index 0000000..149e6cc --- /dev/null +++ b/embed-perry/src/main.ts @@ -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, + ]), +}); diff --git a/native/windows/src/lib.rs b/native/windows/src/lib.rs index 6e7d8b9..eceb9fa 100644 --- a/native/windows/src/lib.rs +++ b/native/windows/src/lib.rs @@ -7,6 +7,13 @@ use std::sync::OnceLock; static mut ENGINE: OnceLock = 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") } } @@ -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] @@ -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() @@ -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 } } @@ -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(); diff --git a/package.json b/package.json index 17dbcbd..0f78379 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,25 @@ "params": [], "returns": "void" }, + { + "name": "bloom_attach_hwnd", + "params": [ + "f64", + "f64", + "f64" + ], + "returns": "void" + }, + { + "name": "bloom_resize", + "params": [ + "f64", + "f64", + "f64", + "f64" + ], + "returns": "void" + }, { "name": "bloom_window_should_close", "params": [], diff --git a/src/core/index.ts b/src/core/index.ts index ad2880e..669097b 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -7,6 +7,8 @@ export { Key, MouseButton } from './keys'; // FFI declarations declare function bloom_init_window(width: number, height: number, title: number, fullscreen: number): void; declare function bloom_close_window(): void; +declare function bloom_attach_hwnd(hwnd: number, width: number, height: number): void; +declare function bloom_resize(physW: number, physH: number, logW: number, logH: number): void; declare function bloom_window_should_close(): number; declare function bloom_begin_drawing(): void; declare function bloom_end_drawing(): void; @@ -133,6 +135,24 @@ export function closeWindow(): void { bloom_close_window(); } +/** + * Embed Bloom inside a host-provided native window — e.g. a Perry UI + * `BloomView` widget. Pass the window handle from `bloomViewGetHwnd(view)` + * and the logical viewport size. Bloom builds its render surface on that + * window and subclasses it for resize/input; the host owns the message loop, + * so drive frames yourself with `beginDrawing()` / `update` / `endDrawing()` + * (do NOT call `runGame`, which blocks). Call once, after the host window is + * shown and laid out (e.g. on the first `onFrame` tick). + */ +export function attachToHwnd(hwnd: number, width: number, height: number): void { + bloom_attach_hwnd(hwnd, width, height); +} + +/** Resize the embedded surface explicitly (physical + logical pixels). */ +export function resize(physW: number, physH: number, logW: number, logH: number): void { + bloom_resize(physW, physH, logW, logH); +} + export function windowShouldClose(): boolean { return bloom_window_should_close() !== 0; } diff --git a/src/index.ts b/src/index.ts index 60e443d..f93db0b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export { - initWindow, closeWindow, windowShouldClose, + initWindow, closeWindow, windowShouldClose, attachToHwnd, resize, beginDrawing, endDrawing, takeScreenshot, clearBackground, setEnvClearFromHdr, setTargetFPS, getDeltaTime, getFPS, getTime, getScreenWidth, getScreenHeight,