Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Emscripten support: wasm & webgpu in browser #309

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
125 changes: 108 additions & 17 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ pub fn build(b: *std.Build) void {
"Enable DirectX 12 GPU-Based Validation (GBV)",
) orelse false,
.zpix_enable = b.option(bool, "zpix-enable", "Enable PIX for Windows profiler") orelse false,
.emscripten = b.option(bool, "emscripten", "Build for linking with emscripten toolchain") orelse false,
Copy link
Member

@hazeycode hazeycode Jun 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of adding another option, I think we should drive emsc builds using Zig's built-in emscripten os target, the same way as when x-compiling other targets. it can be "overridden" to freestanding wherever appropriate.

};
ensureTarget(options.target) catch return;
ensureTarget(options) catch return;
ensureGit(b.allocator) catch return;
ensureGitLfs(b.allocator, "install") catch return;
ensureGitLfs(b.allocator, "pull") catch return;
Expand Down Expand Up @@ -95,22 +96,24 @@ fn packagesCrossPlatform(b: *std.Build, options: Options) void {
const target = options.target;
const optimize = options.optimize;

zopengl_pkg = zopengl.package(b, target, optimize, .{});
zmath_pkg = zmath.package(b, target, optimize, .{});
zpool_pkg = zpool.package(b, target, optimize, .{});
zglfw_pkg = zglfw.package(b, target, optimize, .{});
zsdl_pkg = zsdl.package(b, target, optimize, .{});
zmesh_pkg = zmesh.package(b, target, optimize, .{});
znoise_pkg = znoise.package(b, target, optimize, .{});
zstbi_pkg = zstbi.package(b, target, optimize, .{});
zbullet_pkg = zbullet.package(b, target, optimize, .{});
zglfw_pkg = zglfw.package(b, target, optimize, .{ .options = .{ .emscripten = options.emscripten } });
zgui_pkg = zgui.package(b, target, optimize, .{
.options = .{ .backend = .glfw_wgpu },
.options = .{ .backend = .glfw_wgpu, .emscripten = options.emscripten },
});
zgpu_pkg = zgpu.package(b, target, optimize, .{
.options = .{ .uniforms_buffer_size = 4 * 1024 * 1024 },
.options = .{ .uniforms_buffer_size = 4 * 1024 * 1024, .emscripten = options.emscripten },
.deps = .{ .zpool = zpool_pkg.zpool, .zglfw = zglfw_pkg.zglfw },
});
if (options.emscripten) return;

zopengl_pkg = zopengl.package(b, target, optimize, .{});
zsdl_pkg = zsdl.package(b, target, optimize, .{});
zmesh_pkg = zmesh.package(b, target, optimize, .{});
znoise_pkg = znoise.package(b, target, optimize, .{});
zstbi_pkg = zstbi.package(b, target, optimize, .{});
zbullet_pkg = zbullet.package(b, target, optimize, .{});
ztracy_pkg = ztracy.package(b, target, optimize, .{
.options = .{
.enable_ztracy = !target.isDarwin(), // TODO: ztracy fails to compile on macOS.
Expand Down Expand Up @@ -168,6 +171,7 @@ fn packagesWindows(b: *std.Build, options: Options) void {
fn samplesCrossPlatform(b: *std.Build, options: Options) void {
const minimal_gl = @import("samples/minimal_gl/build.zig");
const triangle_wgpu = @import("samples/triangle_wgpu/build.zig");
const triangle_wgpu_emscripten = @import("samples/triangle_wgpu_emscripten/build.zig");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good way to develop initially but I think we should make the existing cross-platform samples emscripten compatible instead of adding new sample variants. Any samples that have not yet been made compatible can be individually skipped.

const procedural_mesh_wgpu = @import("samples/procedural_mesh_wgpu/build.zig");
const textured_quad_wgpu = @import("samples/textured_quad_wgpu/build.zig");
const physically_based_rendering_wgpu = @import("samples/physically_based_rendering_wgpu/build.zig");
Expand All @@ -179,6 +183,10 @@ fn samplesCrossPlatform(b: *std.Build, options: Options) void {
const gamepad_wgpu = @import("samples/gamepad_wgpu/build.zig");
const physics_test_wgpu = @import("samples/physics_test_wgpu/build.zig");

install_options = options;
install(b, triangle_wgpu_emscripten.build(b, options), "triangle_wgpu_emscripten");
if (options.emscripten) return;

install(b, minimal_gl.build(b, options), "minimal_gl");
install(b, triangle_wgpu.build(b, options), "triangle_wgpu");
install(b, textured_quad_wgpu.build(b, options), "textured_quad_wgpu");
Expand Down Expand Up @@ -298,9 +306,13 @@ pub const Options = struct {
zd3d12_enable_gbv: bool,

zpix_enable: bool,

emscripten: bool = false,
};

var install_options: ?Options = null;
fn install(b: *std.Build, exe: *std.Build.CompileStep, comptime name: []const u8) void {
const emscripten = install_options != null and install_options.?.emscripten;
// TODO: Problems with LTO on Windows.
exe.want_lto = false;
if (exe.optimize == .ReleaseFast)
Expand All @@ -311,12 +323,17 @@ fn install(b: *std.Build, exe: *std.Build.CompileStep, comptime name: []const u8
//comptime var desc_size = std.mem.indexOf(u8, &desc_name, "\x00").?;

const install_step = b.step(name, "Build '" ++ name ++ "' demo");
install_step.dependOn(&b.addInstallArtifact(exe).step);

const run_step = b.step(name ++ "-run", "Run '" ++ name ++ "' demo");
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(install_step);
run_step.dependOn(&run_cmd.step);
if (!emscripten) {
install_step.dependOn(&b.addInstallArtifact(exe).step);

const run_step = b.step(name ++ "-run", "Run '" ++ name ++ "' demo");
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(install_step);
run_step.dependOn(&run_cmd.step);
} else {
const link_step = linkEmscripten(b, install_options.?, exe) catch unreachable;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Linking should not happen inside install

install_step.dependOn(&link_step.step);
}

b.getInstallStep().dependOn(install_step);
}
Expand Down Expand Up @@ -344,7 +361,8 @@ fn ensureZigVersion() !void {
}
}

fn ensureTarget(cross: std.zig.CrossTarget) !void {
fn ensureTarget(options: Options) !void {
const cross = options.target;
const target = (std.zig.system.NativeTargetInfo.detect(cross) catch unreachable).target;

const supported = switch (target.os.tag) {
Expand All @@ -359,6 +377,7 @@ fn ensureTarget(cross: std.zig.CrossTarget) !void {
if (target.os.version_range.semver.min.order(min_available) == .lt) break :blk false;
break :blk true;
},
.freestanding => target.cpu.arch == .wasm32 and options.emscripten,
else => false,
};
if (!supported) {
Expand Down Expand Up @@ -482,3 +501,75 @@ fn ensureGitLfsContent(comptime file_path: []const u8) !void {
inline fn thisDir() []const u8 {
return comptime std.fs.path.dirname(@src().file) orelse ".";
}

pub fn linkEmscripten(b: *std.Build, options: Options, exe: *std.Build.CompileStep) !*std.Build.Step.Run {
var ems_closure: ?[]const u8 = null;
const emsdk_path = b.env_map.get("EMSDK") orelse @panic("Failed to get emscripten SDK path, have you installed & sourced the SDK?");
const emscripten_include = b.pathJoin(&.{ emsdk_path, "upstream", "emscripten", "cache", "sysroot", "include" });
exe.addSystemIncludePath(emscripten_include);
exe.stack_protector = false;
exe.disable_stack_probing = true;
exe.linkLibC();

const emlink = b.addSystemCommand(&.{"emcc"});
emlink.addArtifactArg(exe);
for (exe.link_objects.items) |link_dependency| {
switch (link_dependency) {
.other_step => |o| emlink.addArtifactArg(o),
// .c_source_file => |f| emlink.addFileSourceArg(f.source), // f.args?
// .c_source_files => |fs| for (fs.files) |f| emlink.addArg(f), // fs.flags?
else => {},
}
}
const out_path = b.pathJoin(&.{ b.pathFromRoot("."), "zig-out", "www", exe.name });
std.fs.cwd().makePath(out_path) catch unreachable;
const out_file = try std.mem.concat(b.allocator, u8, &.{ "-o", out_path, std.fs.path.sep_str ++ "index.html" });
emlink.addArgs(&.{"-sEXPORTED_FUNCTIONS=['_malloc','_emmalloc_realloc','_main']"});
//emling.addArgs(&.{"-sLLD_REPORT_UNDEFINED", "-sERROR_ON_UNDEFINED_SYMBOLS=0"});
emlink.addArg(out_file);
emlink.addArg("-sFILESYSTEM=0");
emlink.addArg("-sUSE_GLFW=3");
emlink.addArg("-sUSE_WEBGPU=1");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Library/sample specific flags could be added separately i.e. linkEmscripten only has the common stuff

emlink.addArg("-sASYNCIFY");
//emlink.addArg("-sASYNCIFY_IGNORE_INDIRECT=1");
//emlink.addArg("-sASYNCIFY_ADVISE=1");
//emlink.addArg("-sERROR_ON_UNDEFINED_SYMBOLS=0");
emlink.addArgs(&.{ "-sINITIAL_MEMORY=64MB", "-sMALLOC=emmalloc", "-sABORTING_MALLOC=0", "-sEXIT_RUNTIME=0" });
emlink.addArg("-sALLOW_MEMORY_GROWTH");
emlink.addArgs(&.{ "-fno-rtti", "-fno-exceptions" });
emlink.addArg("-sWASM_BIGINT");

// there are a lot of flags that can be tried used either for debugging or optimization, these are basic defaults
// see: https://emscripten.org/docs/tools_reference/emcc.html#emccdoc
switch (options.optimize) {
.Debug => {
ems_closure = "0";
emlink.addArgs(&.{"-g"});
//emlink.addArgs(&.{"-Og"});
const source_map_base = "./"; // depending on how webserver is configured this might need to be changed
emlink.addArgs(&.{ "-gsource-map", "--source-map-base", source_map_base });
},
.ReleaseSmall => {
emlink.addArgs(&.{"-Os"});
exe.strip = true;
//emlink.addArg("-Oz");
},
.ReleaseFast => {
emlink.addArg("-O2"); // keeps function names, useful for release-debug style build
//emlink.addArgs(&.{"-flto"});
},
.ReleaseSafe => {
emlink.addArg("-O2");
},
}

if (ems_closure) |c| {
emlink.addArgs(&.{ "--closure", c });
}

// custom html shell
//emlink.addArgs(&.{ "--shell-file", "src/shell_simple.html" });

emlink.step.dependOn(&exe.step);
return emlink;
}
11 changes: 11 additions & 0 deletions libs/zglfw/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ const std = @import("std");
pub const Package = struct {
zglfw: *std.Build.Module,
zglfw_c_cpp: *std.Build.CompileStep,
options: Options,

pub fn link(pkg: Package, exe: *std.Build.CompileStep) void {
exe.addModule("zglfw", pkg.zglfw);

const host = (std.zig.system.NativeTargetInfo.detect(exe.target) catch unreachable).target;

if (pkg.options.emscripten) return;

switch (host.os.tag) {
.windows => {},
.macos => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can just leverage the Zigs builtin emscripten target here and treat it like the others.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just commited this. Not sure if its better. Its still tricky because zig modules reuse target from target its linked with (as I understand), now zglfw and zgpu etc. has to assume both .emscripten and .freestanding target means emscripten. It works as long as we don't need any other .freestanding target. But still we have to support wasm32-freestanding and emscripten os, or beter workaround has to be found to be able to use .emscripten target everywhere.

Copy link
Member

@hazeycode hazeycode Jun 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now zglfw and zgpu etc. has to assume both .emscripten and .freestanding target means emscripten

Why? I don't understand. Do you mean that if we compile our game as freestanding we are unable to link zglfw if has been built with emscripten os tag? I thought that it would link because they are both wasm32.

Copy link
Author

@Deins Deins Jun 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically issue is that I had to use wasm32-freestanding instead of emscripten for main user/sample code, otherwise I think I got issues with entry point or or _start , don't rememember, but it didn't work for me.

In regards to zglfw & libs issue is not with c but zig modules that don't support specifying different target for them: build.createModule - can't specify target. addModule() just uses target from exe its added to.
So zig bindings code such as zglfw.zig in end has to assume that builtin.target freestanding is emscripten.
Hope I explained it better this time.

Copy link
Member

@hazeycode hazeycode Jun 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, i follow now. Thanks for clarifying. I'm not sure whether it's better to deal with the entry point or not in the long run; but let's just go with this for now.

Expand Down Expand Up @@ -36,6 +39,7 @@ pub const Package = struct {

pub const Options = struct {
shared: bool = false,
emscripten: bool = false,
};

pub fn package(
Expand All @@ -53,6 +57,12 @@ pub fn package(
.source_file = .{ .path = thisDir() ++ "/src/zglfw.zig" },
});

if (args.options.emscripten) return .{
.zglfw = zglfw,
.zglfw_c_cpp = undefined,
.options = args.options,
};

const zglfw_c_cpp = if (args.options.shared) blk: {
const lib = b.addSharedLibrary(.{
.name = "zglfw",
Expand Down Expand Up @@ -164,6 +174,7 @@ pub fn package(
return .{
.zglfw = zglfw,
.zglfw_c_cpp = zglfw_c_cpp,
.options = args.options,
};
}

Expand Down
5 changes: 5 additions & 0 deletions libs/zgpu/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ pub const Options = struct {
bind_group_pool_size: u32 = 32,
bind_group_layout_pool_size: u32 = 32,
pipeline_layout_pool_size: u32 = 32,

emscripten : bool = false,
};

pub const Package = struct {
Expand All @@ -25,6 +27,8 @@ pub const Package = struct {
exe.addModule("zgpu", pkg.zgpu);
exe.addModule("zgpu_options", pkg.zgpu_options);

if (pkg.options.emscripten) return;

switch (target.os.tag) {
.windows => {
exe.addLibraryPath(thisDir() ++ "/../system-sdk/windows/lib/x86_64-windows-gnu");
Expand Down Expand Up @@ -95,6 +99,7 @@ pub fn package(
step.addOption(u32, "bind_group_pool_size", args.options.bind_group_pool_size);
step.addOption(u32, "bind_group_layout_pool_size", args.options.bind_group_layout_pool_size);
step.addOption(u32, "pipeline_layout_pool_size", args.options.pipeline_layout_pool_size);
step.addOption(bool, "emscripten", args.options.emscripten);

const zgpu_options = step.createModule();

Expand Down
84 changes: 84 additions & 0 deletions libs/zgpu/src/binding_tests.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const std = @import("std");
const testing = std.testing;
const c = @cImport({
@cInclude("webgpu/webgpu.h");
});
const gpu = @import("wgpu.zig");

// test struct sizes
fn assertStructBindings(zig_type: anytype, c_type: anytype) void {
if (@sizeOf(zig_type) != @sizeOf(c_type)) {
@compileLog("emscripten zgpu type has different size from webgpu.h type:", zig_type, c_type, @sizeOf(zig_type), @sizeOf(c_type));
unreachable;
}
// more checks? iterate struct fields?
// emscripten in .Debug mode asserts at runtime if required field is missing etc. but something always slips through
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what is meant by something slips through

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

core issue is why I added these tests is that webgpu.zig basically rewrites c header webgpu.h. By default it matches dawn webgpu.h but emscripten ships with its own that can have version mismatch. If zig structs doesn't match with ones in actual header problems start. For emscripten that means it will try read some struct field but might get value from different field etc. Size checks help to quickly at comptime show these. But if u32 field was removed to struct and other added at end, it will slip through leading to unknown behavior and most likely crash at runtime or worse. But its not straight forward to test at comptime whole struct due to camel-case/snake-case naming changes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michal-z do you have any thoughts regarding how struct layouts can be validated?

}

comptime {
assertStructBindings(gpu.ChainedStruct, c.WGPUChainedStruct);
assertStructBindings(gpu.ChainedStructOut, c.WGPUChainedStructOut);
assertStructBindings(gpu.AdapterProperties, c.WGPUAdapterProperties);
assertStructBindings(gpu.BindGroupEntry, c.WGPUBindGroupEntry);
assertStructBindings(gpu.BlendComponent, c.WGPUBlendComponent);
assertStructBindings(gpu.BufferBindingLayout, c.WGPUBufferBindingLayout);
assertStructBindings(gpu.BufferDescriptor, c.WGPUBufferDescriptor);
assertStructBindings(gpu.Color, c.WGPUColor);
assertStructBindings(gpu.CommandBufferDescriptor, c.WGPUCommandBufferDescriptor);
assertStructBindings(gpu.CommandEncoderDescriptor, c.WGPUCommandEncoderDescriptor);
assertStructBindings(gpu.CompilationMessage, c.WGPUCompilationMessage);
assertStructBindings(gpu.ComputePassTimestampWrite, c.WGPUComputePassTimestampWrite);
assertStructBindings(gpu.ConstantEntry, c.WGPUConstantEntry);
assertStructBindings(gpu.Extent3D, c.WGPUExtent3D);
//assertStructBindings(gpu.InstanceDescriptor, c.WGPUInstanceDescriptor);
assertStructBindings(gpu.Limits, c.WGPULimits);
assertStructBindings(gpu.MultisampleState, c.WGPUMultisampleState);
assertStructBindings(gpu.Origin3D, c.WGPUOrigin3D);
assertStructBindings(gpu.PipelineLayoutDescriptor, c.WGPUPipelineLayoutDescriptor);
//assertStructBindings(gpu.PrimitiveDepthClipControl, c.WGPUPrimitiveDepthClipControl);
assertStructBindings(gpu.PrimitiveState, c.WGPUPrimitiveState);
assertStructBindings(gpu.QuerySetDescriptor, c.WGPUQuerySetDescriptor);
//assertStructBindings(gpu.QueueDescriptor, c.WGPUQueueDescriptor);
assertStructBindings(gpu.RenderBundleDescriptor, c.WGPURenderBundleDescriptor);
assertStructBindings(gpu.RenderBundleEncoderDescriptor, c.WGPURenderBundleEncoderDescriptor);
assertStructBindings(gpu.RenderPassDepthStencilAttachment, c.WGPURenderPassDepthStencilAttachment);
//assertStructBindings(gpu.RenderPassDescriptorMaxDrawCount, c.WGPURenderPassDescriptorMaxDrawCount);
assertStructBindings(gpu.RenderPassTimestampWrite, c.WGPURenderPassTimestampWrite);
assertStructBindings(gpu.RequestAdapterOptions, c.WGPURequestAdapterOptions);
assertStructBindings(gpu.SamplerBindingLayout, c.WGPUSamplerBindingLayout);
assertStructBindings(gpu.SamplerDescriptor, c.WGPUSamplerDescriptor);
assertStructBindings(gpu.ShaderModuleDescriptor, c.WGPUShaderModuleDescriptor);
//assertStructBindings(gpu.ShaderModuleSPIRVDescriptor, c.WGPUShaderModuleSPIRVDescriptor);
//assertStructBindings(gpu.ShaderModuleWGSLDescriptor, c.WGPUShaderModuleWGSLDescriptor);
assertStructBindings(gpu.StencilFaceState, c.WGPUStencilFaceState);
assertStructBindings(gpu.StorageTextureBindingLayout, c.WGPUStorageTextureBindingLayout);
assertStructBindings(gpu.SurfaceDescriptor, c.WGPUSurfaceDescriptor);
assertStructBindings(gpu.SurfaceDescriptorFromCanvasHTMLSelector, c.WGPUSurfaceDescriptorFromCanvasHTMLSelector);
//assertStructBindings(gpu.SwapChainDescriptor, c.WGPUSwapChainDescriptor);
assertStructBindings(gpu.TextureBindingLayout, c.WGPUTextureBindingLayout);
assertStructBindings(gpu.TextureDataLayout, c.WGPUTextureDataLayout);
assertStructBindings(gpu.TextureViewDescriptor, c.WGPUTextureViewDescriptor);
assertStructBindings(gpu.VertexAttribute, c.WGPUVertexAttribute);
assertStructBindings(gpu.BindGroupDescriptor, c.WGPUBindGroupDescriptor);
assertStructBindings(gpu.BindGroupLayoutEntry, c.WGPUBindGroupLayoutEntry);
assertStructBindings(gpu.BlendState, c.WGPUBlendState);
assertStructBindings(gpu.CompilationInfo, c.WGPUCompilationInfo);
assertStructBindings(gpu.ComputePassDescriptor, c.WGPUComputePassDescriptor);
assertStructBindings(gpu.DepthStencilState, c.WGPUDepthStencilState);
assertStructBindings(gpu.ImageCopyBuffer, c.WGPUImageCopyBuffer);
assertStructBindings(gpu.ImageCopyTexture, c.WGPUImageCopyTexture);
assertStructBindings(gpu.ProgrammableStageDescriptor, c.WGPUProgrammableStageDescriptor);
assertStructBindings(gpu.RenderPassColorAttachment, c.WGPURenderPassColorAttachment);
assertStructBindings(gpu.RequiredLimits, c.WGPURequiredLimits);
assertStructBindings(gpu.SupportedLimits, c.WGPUSupportedLimits);
assertStructBindings(gpu.TextureDescriptor, c.WGPUTextureDescriptor);
assertStructBindings(gpu.VertexBufferLayout, c.WGPUVertexBufferLayout);
assertStructBindings(gpu.BindGroupLayoutDescriptor, c.WGPUBindGroupLayoutDescriptor);
assertStructBindings(gpu.ColorTargetState, c.WGPUColorTargetState);
assertStructBindings(gpu.ComputePipelineDescriptor, c.WGPUComputePipelineDescriptor);
assertStructBindings(gpu.DeviceDescriptor, c.WGPUDeviceDescriptor);
assertStructBindings(gpu.RenderPassDescriptor, c.WGPURenderPassDescriptor);
assertStructBindings(gpu.VertexState, c.WGPUVertexState);
assertStructBindings(gpu.FragmentState, c.WGPUFragmentState);
assertStructBindings(gpu.RenderPipelineDescriptor, c.WGPURenderPipelineDescriptor);
}