diff --git a/.github/workflows/zig.yml b/.github/workflows/zig.yml index d577f4a..91d7411 100644 --- a/.github/workflows/zig.yml +++ b/.github/workflows/zig.yml @@ -1,6 +1,7 @@ name: Zig CI (Multiplatform) on: + workflow_dispatch: push: branches: ["*"] pull_request: @@ -33,7 +34,6 @@ jobs: with: version: 0.15.1 - # TODO: Add tests and examples - name: Build Lib for ${{ matrix.target }} run: zig build -Dtarget=${{ matrix.target }} -Doptimize=ReleaseFast --verbose @@ -41,10 +41,20 @@ jobs: if: runner.os == 'Windows' run: zig build -Dtarget=x86_64-windows-msvc -Doptimize=ReleaseFast --verbose --prefix ./zig-out/x86_64-windows + - name: Set up Android SDK/NDK + if: runner.os == 'Linux' + uses: android-actions/setup-android@v3 + with: + packages: "ndk;27.3.13750724 platforms;android-29" + - name: Cross Compile if: runner.os == 'Linux' run: zig build -Doptimize=ReleaseFast all --verbose + - name: Build Android Example + if: runner.os == 'Linux' + run: zig build -Doptimize=ReleaseFast android-example --verbose + - name: Build Examples Linux if: runner.os == 'Linux' run: | diff --git a/build.zig b/build.zig index 2df192b..b1fb19c 100644 --- a/build.zig +++ b/build.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const AndroidSdk = @import("zig_android").Sdk; pub fn build(b: *std.Build) !void { const target = b.standardTargetOptions(.{}); @@ -43,24 +44,29 @@ pub fn build(b: *std.Build) !void { .{ .cpu_arch = .aarch64, .os_tag = .macos }, .{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .gnu }, .{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .musl }, + .{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .android }, .{ .cpu_arch = .x86_64, .os_tag = .windows }, .{ .cpu_arch = .x86_64, .os_tag = .macos }, .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .gnu }, .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .musl }, + .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .android }, .{ .cpu_arch = .aarch64, .os_tag = .ios, .os_version_min = ios_min_version }, .{ .cpu_arch = .aarch64, .os_tag = .ios, .abi = .simulator, .os_version_min = ios_min_version }, .{ .cpu_arch = .x86_64, .os_tag = .ios, .abi = .simulator, .os_version_min = ios_min_version }, }; for (build_targets) |t| { const resolved_target = b.resolveTargetQuery(t); - const lib = try buildLib(b, .{ + const lib = buildLib(b, .{ .target = resolved_target, .optimize = optimize, .use_vma = use_vma, .support_drm = support_drm, .enable_x11 = enable_x11, .enable_wayland = enable_wayland, - }); + }) catch |err| switch (err) { + error.UnsupportedTarget => continue, + else => |e| return e, + }; const target_output = b.addInstallArtifact(lib, .{ .dest_dir = .{ .override = .{ @@ -70,6 +76,67 @@ pub fn build(b: *std.Build) !void { }); build_all_step.dependOn(&target_output.step); } + + // Android APK example + const android_step = b.step("android-example", "Build Android example APK"); + var sdk = AndroidSdk.init(b, 29); + const app = try sdk.createApp(.{ + .manifest = .{ + .package = "com.wgvk.example", + .uses_sdk = .{ + .android_minSdkVersion = 29, + .android_targetSdkVersion = 35, + }, + .application = .{ + .android_label = "WGVK Clear", + .android_hasCode = false, + .activity = &.{.{ + .android_name = "android.app.NativeActivity", + .android_exported = true, + .android_configChanges = &.{ .orientation, .keyboardHidden, .screenSize }, + .meta_data = &.{.{ + .android_name = "android.app.lib_name", + .android_value = "wgvk_android_clear", + }}, + .intent_filter = &.{AndroidSdk.Application.Manifest.IntentFilter.main_launcher}, + }}, + }, + }, + }); + + const android_targets: []const std.Target.Query = &.{ + .{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .android }, + .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .android }, + }; + for (android_targets) |t| { + const resolved = b.resolveTargetQuery(t); + const wgvk_android = buildLib(b, .{ + .target = resolved, + .optimize = optimize, + .use_vma = use_vma, + .support_drm = false, + .enable_x11 = false, + .enable_wayland = false, + }) catch continue; + + const lib = app.addLibrary(.{ + .name = "wgvk_android_clear", + .root_module = b.createModule(.{ + .target = resolved, + .optimize = optimize, + .link_libc = true, + }), + }); + lib.root_module.addIncludePath(b.path("include")); + lib.root_module.addCSourceFile(.{ + .file = b.path("examples/android_clear.c"), + .flags = &.{"-std=c11"}, + }); + lib.root_module.linkLibrary(wgvk_android); + } + + const apk = app.addInstallApk("."); + android_step.dependOn(&apk.step); } const WgvkOptions = struct { @@ -107,6 +174,7 @@ fn buildLib(b: *std.Build, options: WgvkOptions) !*std.Build.Step.Compile { wgvk_mod.link_libcpp = true; } + var is_android = false; switch (options.target.result.os.tag) { .windows => { wgvk_mod.addCMacro("SUPPORT_WIN32_SURFACE", "1"); @@ -122,26 +190,40 @@ fn buildLib(b: *std.Build, options: WgvkOptions) !*std.Build.Step.Compile { wgvk_mod.linkFramework("QuartzCore", .{}); }, else => { - const is_android = options.target.result.abi.isAndroid(); - if (!is_android and options.support_drm) { - wgvk_mod.addCMacro("SUPPORT_DRM_SURFACE", "1"); - } + is_android = options.target.result.abi.isAndroid(); + if (is_android) { + wgvk_mod.addCMacro("SUPPORT_ANDROID_SURFACE", "1"); + } else { + if (options.support_drm) { + wgvk_mod.addCMacro("SUPPORT_DRM_SURFACE", "1"); + } - if (!is_android and options.enable_x11) if (b.lazyDependency("x11_headers", .{})) |x11| { - wgvk_mod.addCMacro("SUPPORT_XLIB_SURFACE", "1"); - wgvk_mod.linkLibrary(x11.artifact("x11-headers")); - }; - if (options.enable_wayland) if (b.lazyDependency("wayland_headers", .{})) |wayland| { - wgvk_mod.addCMacro("SUPPORT_WAYLAND_SURFACE", "1"); - wgvk_mod.addIncludePath(wayland.path("wayland")); - }; + if (options.enable_x11) if (b.lazyDependency("x11_headers", .{})) |x11| { + wgvk_mod.addCMacro("SUPPORT_XLIB_SURFACE", "1"); + wgvk_mod.linkLibrary(x11.artifact("x11-headers")); + }; + if (options.enable_wayland) if (b.lazyDependency("wayland_headers", .{})) |wayland| { + wgvk_mod.addCMacro("SUPPORT_WAYLAND_SURFACE", "1"); + wgvk_mod.addIncludePath(wayland.path("wayland")); + }; + } }, } - const wgvk_lib = b.addLibrary(.{ - .name = "wgvk", - .root_module = wgvk_mod, - }); + const wgvk_lib = + if (!is_android) + b.addLibrary(.{ + .name = "wgvk", + .root_module = wgvk_mod, + }) + else blk: { + var android = AndroidSdk.init(b, 29); + break :blk android.addLibrary(.{ + .name = "wgvk", + .root_module = wgvk_mod, + }); + }; + wgvk_lib.installHeadersDirectory(b.path("./include/webgpu"), "webgpu", .{}); wgvk_lib.installHeader(b.path("./include/wgvk.h"), "wgvk.h"); wgvk_lib.installHeader(b.path("./include/wgvk_config.h"), "wgvk_config.h"); diff --git a/build.zig.zon b/build.zig.zon index 6e4fc81..82400f2 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,7 +1,7 @@ .{ .name = .WGVK, .version = "0.0.1", - .minimum_zig_version = "0.15.1", + .minimum_zig_version = "0.14.0", .paths = .{""}, .fingerprint = 0xfa41188e5ce82b6, .dependencies = .{ @@ -25,5 +25,9 @@ .hash = "glfw-0.0.0-R5AeUo0JGgBvENV2Oz-u0V_unpWhEJTfK4qckBH5da6e", .lazy = true, }, + .zig_android = .{ + .url = "git+https://github.com/shreyassanthu77/zig-android.git#607d4c887654c87c1f8815e78c2c81450300e836", + .hash = "zig_android-0.0.5-Hyn4Y0RcAQCNen2QAzF1ibRfMj35OvbLgkPWW_iK5ZG9", + }, }, } diff --git a/examples/android_clear.c b/examples/android_clear.c new file mode 100644 index 0000000..9cc0302 --- /dev/null +++ b/examples/android_clear.c @@ -0,0 +1,313 @@ +/** + * WGVK Android Example - Clear Screen + * + * Minimal NativeActivity app that initializes WebGPU via WGVK + * and clears the screen to red. No shaders, no geometry. + */ +#include +#include +#include +#include + +#include + +#include "wgvk.h" + +#define LOG_TAG "WGVK" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +#define STRVIEW(str) \ + (WGPUStringView) { .data = str, .length = sizeof(str) - 1 } + +typedef struct { + WGPUInstance instance; + WGPUAdapter adapter; + WGPUDevice device; + WGPUQueue queue; + WGPUSurface surface; + WGPUTextureFormat format; + uint32_t width; + uint32_t height; + int initialized; +} AppState; + +static void on_adapter(WGPURequestAdapterStatus status, + WGPUAdapter adapter, WGPUStringView message, + void *userdata1, void *userdata2) { + (void)status; + (void)message; + (void)userdata2; + *(WGPUAdapter *)userdata1 = adapter; +} + +static void on_device(WGPURequestDeviceStatus status, WGPUDevice device, + WGPUStringView message, void *userdata1, + void *userdata2) { + (void)status; + (void)message; + (void)userdata2; + *(WGPUDevice *)userdata1 = device; +} + +static int init_wgpu(AppState *state) { + // Instance + WGPUInstanceFeatureName features[] = { + WGPUInstanceFeatureName_TimedWaitAny, + }; + WGPUInstanceDescriptor idesc = { + .requiredFeatures = features, + .requiredFeatureCount = 1, + }; + state->instance = wgpuCreateInstance(&idesc); + if (!state->instance) { + LOGE("Failed to create instance"); + return 0; + } + LOGI("Instance created"); + + // Adapter + WGPURequestAdapterOptions aopts = { + .featureLevel = WGPUFeatureLevel_Core, + }; + WGPUFutureWaitInfo wait = { + .future = wgpuInstanceRequestAdapter( + state->instance, &aopts, + (WGPURequestAdapterCallbackInfo){ + .callback = on_adapter, + .userdata1 = &state->adapter, + }), + }; + wgpuInstanceWaitAny(state->instance, 1, &wait, ~(uint64_t)0); + if (!state->adapter) { + LOGE("Failed to get adapter"); + return 0; + } + LOGI("Adapter acquired"); + + // Device + WGPUDeviceDescriptor ddesc = { + .label = STRVIEW("device"), + }; + wait = (WGPUFutureWaitInfo){ + .future = wgpuAdapterRequestDevice( + state->adapter, &ddesc, + (WGPURequestDeviceCallbackInfo){ + .callback = on_device, + .mode = WGPUCallbackMode_WaitAnyOnly, + .userdata1 = &state->device, + }), + }; + wgpuInstanceWaitAny(state->instance, 1, &wait, ~(uint64_t)0); + if (!state->device) { + LOGE("Failed to get device"); + return 0; + } + state->queue = wgpuDeviceGetQueue(state->device); + LOGI("Device acquired"); + + return 1; +} + +static void create_surface(AppState *state, ANativeWindow *window) { + state->width = ANativeWindow_getWidth(window); + state->height = ANativeWindow_getHeight(window); + + WGPUSurfaceSourceAndroidNativeWindow src = { + .chain = {.sType = WGPUSType_SurfaceSourceAndroidNativeWindow}, + .window = window, + }; + WGPUSurfaceDescriptor sdesc = { + .nextInChain = &src.chain, + .label = STRVIEW("surface"), + }; + state->surface = wgpuInstanceCreateSurface(state->instance, &sdesc); + if (!state->surface) { + LOGE("Failed to create surface"); + return; + } + LOGI("Surface created: %ux%u", state->width, state->height); + + // Query preferred format from surface capabilities + WGPUSurfaceCapabilities caps = {0}; + wgpuSurfaceGetCapabilities(state->surface, state->adapter, &caps); + state->format = caps.formatCount > 0 + ? caps.formats[0] + : WGPUTextureFormat_BGRA8Unorm; + LOGI("Using format: %d", state->format); + wgpuSurfaceCapabilitiesFreeMembers(caps); + + wgpuSurfaceConfigure(state->surface, + &(const WGPUSurfaceConfiguration){ + .device = state->device, + .format = state->format, + .width = state->width, + .height = state->height, + .alphaMode = WGPUCompositeAlphaMode_Opaque, + .presentMode = WGPUPresentMode_Fifo, + }); + LOGI("Surface configured"); +} + +static void render_frame(AppState *state) { + if (!state->surface) + return; + + WGPUSurfaceTexture stex; + wgpuSurfaceGetCurrentTexture(state->surface, &stex); + if (stex.status != WGPUSurfaceGetCurrentTextureStatus_SuccessOptimal) + return; + + WGPUTextureView view = wgpuTextureCreateView( + stex.texture, + &(const WGPUTextureViewDescriptor){ + .format = state->format, + .dimension = WGPUTextureViewDimension_2D, + .baseMipLevel = 0, + .mipLevelCount = 1, + .baseArrayLayer = 0, + .arrayLayerCount = 1, + .aspect = WGPUTextureAspect_All, + .usage = WGPUTextureUsage_RenderAttachment, + }); + + WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder( + state->device, &(const WGPUCommandEncoderDescriptor){0}); + + WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass( + enc, &(const WGPURenderPassDescriptor){ + .colorAttachmentCount = 1, + .colorAttachments = + (const WGPURenderPassColorAttachment[]){ + { + .view = view, + .loadOp = WGPULoadOp_Clear, + .storeOp = WGPUStoreOp_Store, + .clearValue = {.r = 1, .g = 0, .b = 0, .a = 1}, + }, + }, + }); + wgpuRenderPassEncoderEnd(pass); + + WGPUCommandBuffer cmd = wgpuCommandEncoderFinish(enc, NULL); + wgpuQueueSubmit(state->queue, 1, &cmd); + + wgpuCommandBufferRelease(cmd); + wgpuRenderPassEncoderRelease(pass); + wgpuCommandEncoderRelease(enc); + wgpuTextureViewRelease(view); + wgpuTextureRelease(stex.texture); + + wgpuSurfacePresent(state->surface); +} + +static void cleanup(AppState *state) { + if (state->surface) { + wgpuSurfaceRelease(state->surface); + state->surface = NULL; + } + if (state->queue) { + wgpuQueueRelease(state->queue); + state->queue = NULL; + } + if (state->device) { + wgpuDeviceRelease(state->device); + state->device = NULL; + } + if (state->adapter) { + wgpuAdapterRelease(state->adapter); + state->adapter = NULL; + } + if (state->instance) { + wgpuInstanceRelease(state->instance); + state->instance = NULL; + } + state->initialized = 0; +} + +// --- NativeActivity callbacks --- + +static void on_window_created(ANativeActivity *activity, + ANativeWindow *window) { + AppState *state = (AppState *)activity->instance; + LOGI("Window created"); + + if (!state->initialized) { + if (!init_wgpu(state)) { + LOGE("WebGPU init failed"); + return; + } + state->initialized = 1; + } + + create_surface(state, window); + render_frame(state); +} + +static void on_window_resized(ANativeActivity *activity, + ANativeWindow *window) { + AppState *state = (AppState *)activity->instance; + uint32_t w = ANativeWindow_getWidth(window); + uint32_t h = ANativeWindow_getHeight(window); + if (w == state->width && h == state->height) + return; + + state->width = w; + state->height = h; + LOGI("Resize: %ux%u", w, h); + + if (state->surface) { + wgpuSurfaceConfigure(state->surface, + &(const WGPUSurfaceConfiguration){ + .device = state->device, + .format = state->format, + .width = w, + .height = h, + .alphaMode = WGPUCompositeAlphaMode_Opaque, + .presentMode = WGPUPresentMode_Fifo, + }); + render_frame(state); + } +} + +static void on_window_redraw(ANativeActivity *activity, + ANativeWindow *window) { + (void)window; + render_frame((AppState *)activity->instance); +} + +static void on_window_destroyed(ANativeActivity *activity, + ANativeWindow *window) { + (void)window; + AppState *state = (AppState *)activity->instance; + LOGI("Window destroyed"); + if (state->surface) { + wgpuSurfaceRelease(state->surface); + state->surface = NULL; + } +} + +static void on_destroy(ANativeActivity *activity) { + AppState *state = (AppState *)activity->instance; + LOGI("Destroying"); + cleanup(state); + free(state); +} + +JNIEXPORT void ANativeActivity_onCreate(ANativeActivity *activity, + void *savedState, + size_t savedStateSize) { + (void)savedState; + (void)savedStateSize; + + LOGI("onCreate"); + + AppState *state = calloc(1, sizeof(AppState)); + activity->instance = state; + + activity->callbacks->onNativeWindowCreated = on_window_created; + activity->callbacks->onNativeWindowResized = on_window_resized; + activity->callbacks->onNativeWindowRedrawNeeded = on_window_redraw; + activity->callbacks->onNativeWindowDestroyed = on_window_destroyed; + activity->callbacks->onDestroy = on_destroy; +}