Porting PBRT to WebAssembly
2020-06-13 13:00
Porting to WebAssembly
This is a log of steps to port pbrt to Wasm. The final results are on my fork of the pbrt project:
- the version that runs using nodejs and a postprocessing step is on wasm-port
- the version that runs using just nodejs (without postprocessing) is wasm-port-nodejs-fs
- the version that runs in browser and draws to a canvas is wasm-port-web
I tried to keep the changes to pbrt as minimal as possible. This requires some hacking in git submodules, extracted as diffs here, and also hacking in the CMakeLists.txt 1
Clone and setup env
Set up Emscripten using emsdk:
git clone https://github.com/emscripten-core/emsdk
cd emsdk
./emsdk install latest
./emsdk activate latest
source emsdk_env.sh
Clone pbrt:
git clone --recursive https://github.com/mmp/pbrt-v3/
cd pbrt-v3
The rest of the commands will assume you are in pbrt-v3/
.
Emcmake and emmake
Emscripten provides wrappers around cmake
and make
, these will set the variables defining the C++ compiler to the right values so that we can target Wasm.
# create a folder for build outputs
mkdir wasm-build
cd wasm-build
emcmake cmake ..
emmake make -j
Pthreads
Here we hit our first error:
wasm-ld: error: 'atomics' feature is used by CMakeFiles/eLut.dir/eLut.cpp.o, so --shared-memory must
be used
em++: error: '/Users/ngzhian/src/emsdk/upstream/bin/wasm-ld -o
/var/folders/1n/02brqmc95d51bdrmzz8hhmrc0000gn/T/emscripten_temp_j5v5tu4f/eLut.wasm
CMakeFiles/eLut.dir/eLut.cpp.o -L/Users/ngzhian/src/emsdk/upstream/emscripten/system/local/lib
-L/Users/ngzhian/src/emsdk/upstream/emscripten/system/lib
-L/Users/ngzhian/src/emsdk/upstream/emscripten/cache/wasm
/Users/ngzhian/src/emsdk/upstream/emscripten/cache/wasm/libc.a
/Users/ngzhian/src/emsdk/upstream/emscripten/cache/wasm/libcompiler_rt.a
/Users/ngzhian/src/emsdk/upstream/emscripten/cache/wasm/libc-wasm.a
/Users/ngzhian/src/emsdk/upstream/emscripten/cache/wasm/libc++-noexcept.a
/Users/ngzhian/src/emsdk/upstream/emscripten/cache/wasm/libc++abi-noexcept.a
/Users/ngzhian/src/emsdk/upstream/emscripten/cache/wasm/libdlmalloc.a
/Users/ngzhian/src/emsdk/upstream/emscripten/cache/wasm/libpthread_stub.a
/Users/ngzhian/src/emsdk/upstream/emscripten/cache/wasm/libc_rt_wasm.a
/Users/ngzhian/src/emsdk/upstream/emscripten/cache/wasm/libsockets.a -mllvm
-combiner-global-alias-analysis=false -mllvm -enable-emscripten-sjlj -mllvm -disable-lsr
--allow-undefined --import-memory --import-table --strip-debug --export main --export malloc
--export free --export __data_end --export __wasm_call_ctors --export __errno_location -z
stack-size=5242880 --initial-memory=16777216 --no-entry --max-memory=16777216 --global-base=1024'
failed (1)
This error tells us that eLut
was using pthreads, but we did not pass -s USE_PTHREADS=1
when compiling and linking.
The easiest way to fix this is to use CMAKE_CXX_FLAGS
. For now we hack it inside of the check that the compiler is Clang, since Emscripten uses Clang. 2
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -75,6 +75,8 @@ IF(CMAKE_COMPILER_IS_GNUCXX)
ELSEIF(CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
SET(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11")
SET(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-deprecated-register")
+ SET(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -s USE_PTHREADS=1")
+ SET(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -s USE_PTHREADS=1")
ELSEIF(CMAKE_CXX_COMPILER_ID STREQUAL "Intel")
SET(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11")
SharedArrayBuffer
The second error we hit is
...
requested a shared WebAssembly.Memory but the returned buffer is not a SharedArrayBuffer, indicating that while the browser has SharedArrayBuffer it does not have WebAssembly threads support - you may need to set a flag
Emscripten translates pthreads
into use of SharedArrayBuffer
. SharedArrayBuffer
is a shared memory, and for security reasons, is not enabled by default.
Here, toFloat.cpp
is compiled to toFloat.js
, and toFloat.js
is ran using node in order to generate toFloat.h
. toFloat
uses pthreads to parallelize the generation of some constant values. So, in order to run it correctly, we need to pass the --experimental-wasm-threads
flag to node.
We know that node
is found and decided by Emscripten.cmake
, so we can set this variable ourselves. I couldn’t even find this variable in CMake 3.1.0 docs, so I’m quite surprised it even worked, and this is one reason why this project should upgrade the CMake version.
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -4,6 +4,8 @@
CMAKE_MINIMUM_REQUIRED ( VERSION 3.1.0 )
+SET(CMAKE_CROSSCOMPILING_EMULATOR "${CMAKE_SOURCE_DIR}/node-wrapper" CACHE STRING "Path to the emulator for the target system." FORCE)
+
# For sanitizers
SET (CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake" ${CMAKE_MODULE_PATH})
node-wrapper
literally wraps node, passing it the appropriate flag:
$ cat ../node-wrapper
#!/bin/bash
/path/to/emsdk/node/12.9.1_64bit/bin/node --experimental-wasm-threads "$@"
Hacking submodules
The next error is quite cryptic:
/bin/sh: ./dwaLookups: No such file or directory
For this we need to dig into OpenEXR/IlmImf/CMakeLists.txt:
ADD_CUSTOM_COMMAND (
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/dwaLookups.h
COMMAND ${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_CFG_INTDIR}/dwaLookups > ${CMAKE_CURRENT_BINARY_DIR}/dwaLookups.h
DEPENDS dwaLookups
)
Like toFloat.js
, here an executable dwaLookups.js
is generated, and ran using CMAKE_CROSSCOMPILING_EMULATOR
. However, I suspect that the way this custom command was defined broke something. Comparing it to toFloat
:
ADD_CUSTOM_COMMAND(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/toFloat.h
COMMAND toFloat ARGS > ${CMAKE_CURRENT_BINARY_DIR}/toFloat.h
DEPENDS toFloat
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
)
See how it only uses toFloat
instead of trying to construct a path? I tried to look at the upstream for a fix, but they don’t even do this way of generating the header files anymore (these header files are precomputed values), they check it in directly.
So the fix is to change the CMakeLists.txt
, and while we are here, also fix up the potential issue with b44ExpLogTable
:
--- a/OpenEXR/IlmImf/CMakeLists.txt
+++ b/OpenEXR/IlmImf/CMakeLists.txt
@@ -15,7 +15,7 @@ TARGET_LINK_LIBRARIES ( b44ExpLogTable
ADD_CUSTOM_COMMAND (
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/b44ExpLogTable.h
- COMMAND ${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_CFG_INTDIR}/b44ExpLogTable > ${CMAKE_CURRENT_BINARY_DIR}/b44ExpLogTable.h
+ COMMAND b44ExpLogTable > ${CMAKE_CURRENT_BINARY_DIR}/b44ExpLogTable.h
DEPENDS b44ExpLogTable
)
@@ -32,7 +32,7 @@ TARGET_LINK_LIBRARIES ( dwaLookups
ADD_CUSTOM_COMMAND (
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/dwaLookups.h
- COMMAND ${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_CFG_INTDIR}/dwaLookups > ${CMAKE_CURRENT_BINARY_DIR}/dwaLookups.h
+ COMMAND dwaLookups > ${CMAKE_CURRENT_BINARY_DIR}/dwaLookups.h
DEPENDS dwaLookups
)
I much prefer dwaLookups.h
to be checked in, I think it’s a static file anyway, and it takes a long time for the generated dwaLookups.js
to run. (Maybe a performance issue worth looking into, but probably not given upstream has checked in the header.)
At this point or later if you rebuild, you see that dwaLookups.h
take a really long time to complete. I suspect there is a deadlock somewhere, since generating dwaLookups.h
uses pthreads, but the library wasn’t necessary written with Emscripten in mind. The quick fix is to make it single-threaded:
--- a/OpenEXR/IlmImf/dwaLookups.cpp
+++ b/OpenEXR/IlmImf/dwaLookups.cpp
@@ -492,7 +492,7 @@ generateLutHeader()
}
}
- if (IlmThread::supportsThreads()) {
+ if (false && IlmThread::supportsThreads()) {
std::vector<LutHeaderWorker::Runner*> runners;
for (size_t i=0; i<workers.size(); ++i) {
runners.push_back( new LutHeaderWorker::Runner(*workers[i], (i==0)) );
It should now complete (but still takes a while).
glog
The next error we have is:
/Users/ngzhian/src/pbrt-v3-wasm/src/ext/glog/src/raw_logging.cc:153:3: error: use of undeclared identifier 'syscall'
safe_write(STDERR_FILENO, buffer, strlen(buffer));
^
/Users/ngzhian/src/pbrt-v3-wasm/src/ext/glog/src/raw_logging.cc:63:34: note: expanded from macro 'safe_write'
# define safe_write(fd, s, len) syscall(SYS_write, fd, s, len)
Emscripten defines some syscalls, but not all.
I don’t understand what this code is doing, so the fix is to set HAVE_SYSCALL_H
and HAVE_SYS_SYSCALL_H
to false.
SET(HAVE_SYSCALL_H 0 CACHE INTERNAL "Hack for glog" FORCE)
SET(HAVE_SYS_SYSCALL_H 0 CACHE INTERNAL "Hack for glog" FORCE)
undefined symbols
The next error is has to do with undefined symbol, popen
:
error: undefined symbol: popen (referenced by top-level compiled C/C++ code)
warning: Link with `-s LLD_REPORT_UNDEFINED` to get more information on undefined symbols
warning: To disable errors for undefined symbols use `-s ERROR_ON_UNDEFINED_SYMBOLS=0`
warning: _popen may need to be added to EXPORTED_FUNCTIONS if it arrives from a system library
Error: Aborting compilation due to previous errors
em++: error: '/Users/ngzhian/src/emsdk/node/12.9.1_64bit/bin/node /Users/ngzhian/src/emsdk/upstream/emscripten/src/compiler.js /var/folders/1n/02brqmc95d51bdrmzz8hhmrc0000gn/T/tmpvw86fm__.txt' failed (1)
It tells us that we can disable this error, so let’s try it with:
Inline data files
With all these changes, the compilation succeeds. We can try running pbrt.js
with node, but nothing really happens, because we need to pass it an input file.
$ node --experimental-wasm-threads pbrt.js < ../scenes/killeroo-simple.pbrt
<stdin>:45:37: Error: geometry/killeroo.pbrt: No such file or directory
<stdin>:49:37: Error: geometry/killeroo.pbrt: No such file or directory
...
The input file killeroo-simple.pbrt
uses an include
statement to include a geometery file (this a DSL specific to pbrt). There are two paths we can do there:
- preload/embed the files using Emscripten, or
- inline
geometry/killeroo.pbrt
- literally copy paste the contents
The second option seeemed easier so I just did that.
OOM
After this, we get OOM messages. It helpfully suggests us to rebuild with -s ASSERTIONS=1
.
In general, building with this flag is useful, it emits warnings and suggests fixes. In this case, I know what the problem is: the program uses malloc to allocate memory for the large amounts of data structures required for rendering, and at some point we run out of memory. When running on the web, you don’t have access to all of the memory the machine has, there is a cap of 2 or 4 Gb. Emscripten sets the limit to even lower, 256 Wasm pages (64KB) or 16 Mb, so programs hit OOM even earlier. We can compile with the ability to dynamically ask for more memory, or we can compile with a higher initial memory. We do the latter here:
Then after this, it should run fine, with
$ node --experimental-wasm-threads pbrt.js < ../scenes/killeroo-simple.pbrt
Infinite loop?
Well, not really, there’s not much progress, we don’t see any output, so let’s turn up the verbosity (these are flags provided by the glog library that pbrt uses):
$ node --experimental-wasm-threads pbrt.js --logtostderr --minloglevel 0 < ../scenes/killeroo-simple.pbrt
...
I0607 15:18:39.470999 2969088 film.cpp:60] Created film with full resolution [ 700, 700 ]. Crop
window of [ [ 0, 0 ] - [ 1, 1 ] ] -> croppedPixelBounds [ [ 0, 0 ] - [ 700, 700 ] ]
I0607 15:18:39.954999 2969088 bvh.cpp:210] BVH created with 118377 nodes for 66533 primitives (3.61
MB), arena allocated 6.00 MB
We see that this is stuck after BVH
creation. BVH is a data structure to accelerate ray tracing, the details are not important here.
After some debugging, I found that where this was getting stuck is in the creation of ProgressReporter
, called by the SamplerIntegrator. The constructor of ProgressReporter
uses some synchronization primitives to disable profiling, using a barrier, and I think that’s not working well. The role of the ProgressReporter
is to, well, report progress using a pretty progress bar in the terminal. Fortunately, this logic is guarded by a flag, so we can pass --quiet
to ignore this.
$ node --experimental-wasm-threads pbrt.js --logtostderr --minloglevel 0 --quiet <
../scenes/killeroo-simple.pbrt
... bunch of output with "image tile"
Emscripten’s virtual filesystem
And then after that it should run fine. But we don’t really see anything. This is because pbrt writes the output file to the filesytem. But since Emscripten emulates the file system using an in-memory file system, the file is ephemeral and disappears after pbrt.js runs to the end.
There are two fixes here:
- write directly to stdout
- use NODEFS, this maps local directories onto Emscripten’s virtual filesystem via node’s API, this will only work on node, or
I initially went with writing directly to stdout, but afterwards discovered NODEFS, which is a much simpler solution. We’ll cover both cases here:
Writing to stdout
We need to be aware that other parts of the program can be writing to stdout too. So let’s run with with all the logging removed and the quiet flag set:
$ node --experimental-wasm-threads pbrt.js --quiet < ../scenes/killeroo-simple.pbrt > test.pbm
Note: Emscripten supports other kinds of filesystem, e.g. NODEFS for use when running on node, and will be able to persist files written onto disk. I found this out just as I was editing this blog post after I finished writing it… If I had discovered this earlier, I would have used it. I chose to not update this blog spot, since I’ve already wrote the following chapters, and they contain useful tidbits, like UTF-8 when writing to stdout.
UTF-8 output
However, if we open test.pbm
, we see something nonsensical, just a bunch of noise. It is obviously not what we want.
The problem here is, whenever we print/write something to stdout, Emscripten converts it into UTF-8, since that’s what JavaScript understands. So the buffer passed to fwrite
is treated as a UTF-8 encoded bytes, checked for valid encoding, and written out. However, our bytes are arbitrary floats, so we end up with nonsense.
I don’t have a great way to fix this, so what we will do is post-process the output. Instead of writing bytes, we write ASCII values of the bytes, then have a postprocessing step outside of Emscripten that converts them back into actual bytes.3
The change to write the hex values to stdout is:
--- a/src/core/imageio.cpp
+++ b/src/core/imageio.cpp
@@ -439,7 +439,7 @@ static bool WriteImagePFM(const std::string &filename, const Float *rgb,
FILE *fp;
float scale;
- fp = fopen(filename.c_str(), "wb");
+ fp = fopen("/dev/stdout", "wb");
if (!fp) {
Error("Unable to open output PFM file \"%s\"", filename.c_str());
return false;
@@ -465,11 +465,12 @@ static bool WriteImagePFM(const std::string &filename, const Float *rgb,
for (int y = height - 1; y >= 0; y--) {
// in case Float is 'double', copy into a staging buffer that's
// definitely a 32-bit float...
- for (int x = 0; x < 3 * width; ++x)
+ for (int x = 0; x < 3 * width; ++x) {
scanline[x] = rgb[y * width * 3 + x];
- if (fwrite(&scanline[0], sizeof(float), width * 3, fp) <
- (size_t)(width * 3))
- goto fail;
+ int i;
+ memcpy(&i, &scanline[x], sizeof(float));
+ fprintf(fp, "%.8x", i);
+ }
}
fclose(fp);
The %.8x
is important, it ensures that we always write 8 chars. If we have a float whose whose top 4 bits are 0, we will end up only writing 7 characters. This was a bug that I encountered.
Post-processing
The post-processing is a little Python script to read in the floats written out in their hex form, and then write out the actual bytes.
# postprocess.py
with open('test.pfm') as f:
with open('out.pfm', 'wb') as o:
# read headers
for i in range(3):
o.write(bytearray(f.readline(), encoding='utf-8'))
# last byte is new line, don't need it
data = f.readline()[:-1]
# read 8 characters at a time, they form a single float
for i in range(0, len(data), 8):
fp = data[i:i+8];
# write out in little endian
ints = [int(fp[i*2:i*2+2], 16) for i in range(3,-1,-1)]
o.write(bytearray(ints))
Now, to put these two steps together:
../node-wrapper pbrt.js --quiet < ../scenes/killeroo-simple.pbrt > test.pbm
python3 ../postprocess.py
With that, we get out.pfm
that is a valid file! And it looks very similar to the one generate from the native version of pbrt. If you dump the hex and diff them, you see that they differ slightly, I attribute that to floating points, maybe there is some precision error that happened during the conversion.
NODEFS
To use NODEFS, we need to pass -s NODERAWFS=1
to Emscripten4
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -84,6 +84,7 @@ ELSEIF(CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
SET(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -s USE_PTHREADS=1")
SET(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -s ERROR_ON_UNDEFINED_SYMBOLS=0")
SET(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -s INITIAL_MEMORY=134217728")
+ SET(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -s NODERAWFS=1")
ELSEIF(CMAKE_CXX_COMPILER_ID STREQUAL "Intel")
SET(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11")
And to run it:
../node-wrapper pbrt.js --quiet < ../scenes/killeroo-simple.pbrt
After a few moments, you should see killeroo-simple.exr5.
Running in a browser
So far what we have achieved is to able to run pbrt as a command line tool, which is its original use case. However, part of porting to WebAssembly is to enable it to run on the Web (browser). So let’s tackle that now.
Compiling to HTML
If we were using emcc
as a standalone tool, we can specify -o output.html
to request a HTML to be generated (along with the usual .js and .wasm file). With CMake, it is a bit more tricky. We can set the CMake variable CMAKE_EXECUTABLE_SUFFIX
to be .html
, but this will cause our dwaLookups
and b44ExpLogTable
to be generated as .html
, and then we will end up calling node with the html files, which is wrong. I suspect this has something to do with the CMake version. So, our hacky way is to continue to use CMake to build all the .js files, but then for the final “link” step to generate a HTML, we run the command manually:
em++ -std=c++11 -Wno-deprecated-register -s USE_PTHREADS=1 \
-s ERROR_ON_UNDEFINED_SYMBOLS=0 -s INITIAL_MEMORY=134217728 \
@CMakeFiles/pbrt_exe.dir/objects1.rsp -o pbrt.html \
@CMakeFiles/pbrt_exe.dir/linklibs.rsp
I got this command from this file that CMake generated: CMakeFiles/pbrt_exe.dir/link.txt.
The first thing we need to do is remove NODERAWFS
from our CMakeLists.txt, since we will no no longer be running in node environment:
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -84,7 +84,6 @@ ELSEIF(CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
SET(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -s USE_PTHREADS=1")
SET(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -s ERROR_ON_UNDEFINED_SYMBOLS=0")
SET(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -s INITIAL_MEMORY=134217728")
- SET(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -s NODERAWFS=1")
ELSEIF(CMAKE_CXX_COMPILER_ID STREQUAL "Intel")
SET(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++11")
Blocking on main thread
After we build, we should get a pbrt.html file. We cannot simply open this in the browser, Wasm files need to be served via a web server. So, spin up a simple server:
python -m SimpleHTTPServer
Then go to localhost:8000
, and open up the DevTools console, and you will see this message:
Blocking on the main thread is very dangerous, see https://emscripten.org/docs/porting/pthreads.html#blocking-on-the-main-browser-thread
That link provides more details on what the browser main thread is, and why it is dangerous to block on the main thread.
For our purposes, pbrt does a lot of work on the main thread, like parsing the input file, and doing the actual rendering. The behavior we observe is that the spinner on the top of the page keeps spinning, and the page becomes unresponsive.
It will require a bit of rearchitecting of pbrt to properly fix this, so we will use a tip given by the link above, which is to proxy our main thread to a pthread. This moves main()
onto a pthread (web worker), which we can enable with the flag PROXY_TO_PTHREAD
.
em++ -std=c++11 -Wno-deprecated-register -s USE_PTHREADS=1 \
-s ERROR_ON_UNDEFINED_SYMBOLS=0 -s INITIAL_MEMORY=134217728 \
@CMakeFiles/pbrt_exe.dir/objects1.rsp -o pbrt.html \
@CMakeFiles/pbrt_exe.dir/linklibs.rsp \
-s PROXY_TO_PTHREAD
Opening pbrt.html, now we see an input prompt. When not given any arguments, pbrt defaults to reading from stdin, and Emscripten will compile that to reading from a page’s input prompt. I guess we could copy and paste the entire killeroo-simple.pbrt file into the prompt, but let’s do this in a more convenient way.
Passing arguments
When we had the js file, running via node was simple. Arguments passed to node was forwarded to the underlying script. With a HTML file, it’s not as straightforward. Luckily for us, Emscripten provides the Module object.
Opening up pbrt.html6, we can see var Module = { ... }
. Now, we can directly edit this file to add a arguments
property, but every time we run em++
, our changes will be lost. Emscripten has a way for us to systematically add properties to Module via the --pre-js
:
$ cat pre.js
Module['arguments'] = ['scenes/killeroo-simple.pbrt'];
$ em++ -std=c++11 -Wno-deprecated-register -s USE_PTHREADS=1 \
-s ERROR_ON_UNDEFINED_SYMBOLS=0 -s INITIAL_MEMORY=134217728 \
@CMakeFiles/pbrt_exe.dir/objects1.rsp -o pbrt.html \
@CMakeFiles/pbrt_exe.dir/linklibs.rsp \
-s PROXY_TO_PTHREAD \
--pre-js pre.js
And we see a (familiar error message in the console):
Error: scenes/killeroo-simple.pbrt: No such file or directory
Preloading files
We saw this error previously because killeroo-simple.pbrt was including geometry/killeroo.pbrt, and we “fixed” it by copy pasting. Now we need to fix it a bit more correctly.
Emscripten allows you to package files into your compiled output via --preload-file
, these files will be added to the virtual filesystem and available to your compiled code:
em++ -std=c++11 -Wno-deprecated-register -s USE_PTHREADS=1 \
-s ERROR_ON_UNDEFINED_SYMBOLS=0 -s INITIAL_MEMORY=134217728 \
@CMakeFiles/pbrt_exe.dir/objects1.rsp -o pbrt.html \
@CMakeFiles/pbrt_exe.dir/linklibs.rsp \
-s PROXY_TO_PTHREAD \
--pre-js pre.js \
--preload-file ../scenes
With this, we should see that our renderer runs successfully, but nothing really is happening. We expect that killeroo-simple.exr
is written out, so we can check this with Emscripten’s Filesystem API. Open up the DevTools console, and run:
FS.stat("killeroo-simple.exr")
{dev: 1, ino: 27, mode: 33206, nlink: 1, uid: 0, …}
Drawing onto the canvas
Having the file on the filesystem is a sign of success, and it will be cooler to actually have something visible on screen. The HTML page generated by Emscripten has a canvas right in the middle, so let’s attempt to draw the output image onto the canvas.
By default, a exr
file is generated. We also have the option of generating a pfm
file, which is the Netpbm format. This is a simpler format to work with and we will directly read and parse the written file, convert it into pixel data for the canvas:
$ cat pre.js
const outputFile = 'output.pfm';
Module['arguments'] = ['--outfile', outputFile, 'scenes/killeroo-simple.pbrt'];
We could use EM_ASM
to directly “paint” to the canvas, but if we try accessing Module.canvas
directly, we will very quickly see that it is undefined. Since our main()
is now on a worker thread, worker threads don’t have access to the DOM, and so Module.canvas
is inaccessible!
There is a workaround for this. Looking around settings.js, we see OFFSCREENCANVAS_SUPPORT
, this allows us to transfer the canvas to a worker thread. I initially tried this, but found that the I couldn’t access the FS properly from the worker thread with this. scenes/killeroo-simple.pbrt was not found, and neither was output.pfm.
Another way of doing this is with the MAIN_THREAD_EM_ASM
macro, this runs a snippet of JS on the main thread:
--- a/src/core/imageio.cpp
+++ b/src/core/imageio.cpp
@@ -40,6 +40,10 @@
#include <ImfRgba.h>
#include <ImfRgbaFile.h>
+#if __EMSCRIPTEN__
+#include "emscripten.h"
+#endif // __EMSCRIPTEN__
+
namespace pbrt {
// ImageIO Local Declarations
@@ -473,6 +477,13 @@ static bool WriteImagePFM(const std::string &filename, const Float *rgb,
}
fclose(fp);
+
+#if __EMSCRIPTEN__
+ MAIN_THREAD_EM_ASM({
+ drawPFMToCanvas($0, $1);
+ }, width, height);
+#endif // __EMSCRIPTEN__
+
return true;
fail:
After writing to output.pfm, and fclose
is called to completely flush all the bytes, we will call a JS function (defined later by us), passing it the width and height of our image. This will make our canvas flexible to different sizes.
What remains is to define drawPFMToCanvas
. What this function will do is:
- Use Emscripten’s FS api to read the output file (output.pfm)
- Parse Netpbm 32-bit float format
- Convert into pixel data
- Copy pixel data into canvas
There are many ways to write this, I went with this:
function drawPFMToCanvas(width, height) {
Module.canvas.width = width;
Module.canvas.height = height;
var ctx = Module.canvas.getContext('2d');
var imageData = ctx.createImageData(width, height);
var contents = FS.readFile(outputFile);
// netpbm format (https://en.wikipedia.org/wiki/Netpbm#32-bit_extensions)
// PF\n
// width height\n
// endian\n
// 32-bit floats...
// Skip 3 new lines.
const nl = 10; // Uint8 representing new line (ASCII).
const lastNewLine = contents.indexOf(nl, contents.indexOf(nl, contents.indexOf(nl, 0) + 1) + 1);
const data = contents.slice(lastNewLine+1);
// Each pixel is made up of 3 (RGB) 32-bit floats.
if (data.byteLength != width * height * 3 * 4) {
throw 'Mismatch in size';
}
// Uses platform endianness, to be more robust, we should check the endianness encoded in the pfm file.
const floats = new Float32Array(data.buffer);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
// r g b
imageData.data[(height-y-1) * width * 4 + (x * 4 + 0)] = floats[y * width * 3 + (x * 3 + 0)] * 255;
imageData.data[(height-y-1) * width * 4 + (x * 4 + 1)] = floats[y * width * 3 + (x * 3 + 1)] * 255;
imageData.data[(height-y-1) * width * 4 + (x * 4 + 2)] = floats[y * width * 3 + (x * 3 + 2)] * 255;
imageData.data[(height-y-1) * width * 4 + (x * 4 + 3)] = 255; // Full alpha.
}
}
ctx.putImageData(imageData, 0, 0);
}
There are a couple of canvas specific logic in this code, if you are interested in what’s happening, I encourage you to look at MDN’s documentation on Canvas. The important bits are:
- Netpbm format starts with 3 lines of header, ‘’ delimited, followed by 32-bit floats, describing the image in RGB format, so there will be (width * height * 3) 32-bit floats in total.
- The pixel data backing a cavans is specified in RGBA format, 1 byte for each channel, with valid values between 0 and 255.
- The confusing arithmetic has to do with the different ways Netpbm and canvans describes pixel data:
- Netpbm goes left to right, bottom to top
- Canvas goes left to right, top to bottom
height-y-1
logic. TheA
channel should always be 255, otherwise it defaults to 0, full alpha transparency, so the entire image will be black.
With this, linking everything together:
em++ -std=c++11 -Wno-deprecated-register -s USE_PTHREADS=1 \
-s ERROR_ON_UNDEFINED_SYMBOLS=0 -s INITIAL_MEMORY=134217728 \
@CMakeFiles/pbrt_exe.dir/objects1.rsp -o pbrt.html \
@CMakeFiles/pbrt_exe.dir/linklibs.rsp \
-s PROXY_TO_PTHREAD \
--pre-js pre.js \
--preload-file ../scenes
Opening the file, we should see some output in the console, and eventually7 the canvas will be updated with our rendered image:
Concluding thoughts
This was a relatively fun exercise in porting a good sized project to WebAssembly. I hope this step by step exercise gives you an idea of what sort of issues you might encounter while porting, and also tips on how to look at the error messages and figure out how to fix things.
Emscripten is a big project and contains many useful flags to help you port. Turning them (ASSERTIONS
) on during the porting process can probably save you some time. The website contains a lot of useful information, and also test cases in the Emscripten codebase shows how APIs can be used. Those will serve as good examples for porting.
Ideally the submodule versions should be upgraded, but I’m not familiar with the changes the authors made (the submodules are all forked versions of the upstream). Also the CMake version is quite old, so I’ll be doing some hacks that won’t be necessary or look very different if CMake version used was upgraded].↩︎
If a newer Cmake version was used, we can use
target_compile_options
.↩︎Actually if we had turned
ASSERTIONS
on, we would have seen:Invalid UTF-8 leading byte 0x-79 encountered when deserializing a UTF-8 string on the asm.js/wasm heap to a JS string!
and this is an indication that what we are trying might not work.↩︎
I looked this up in Emscripten’s settings.js. This is the file where all arguments to
-s
is set, so you can findUSE_PTHREADS
,ASSERTIONS
here too.↩︎If you can’t open exr files, you can overwrite the output file format by specifying
--outfile output.pfm
.↩︎The pbrt.html file is minified if we are compiling with
-O3
, all whitespaces and newlines are stripped, making it really hard to read. If you want to take a closer look, either pass it to a HTML formatter, or compile with-O1
.↩︎To speed up the rendering, so you can see output faster to determine if something is wrong, you can change the resolution of the output image. Edit killeroo-simple.pbrt, look for the line with
integer xresolution
, change the values700
to something like50
. Then rendering should only take a couple of seconds.↩︎