feat: Update goldens and player to deploy in the browser (#10453) 827077b899

Set up http and websocket servers in deploy_tests.py that allow us to
communicate with the remote wasm app similarly to how we communicate
with android & ios devices. Add a "-w" target to check_golds.sh that
kicks tests off in the default browser.

Co-authored-by: Chris Dalton <99840794+csmartdalton@users.noreply.github.com>
This commit is contained in:
csmartdalton
2025-08-27 02:58:49 +00:00
parent a02b5b9e9d
commit 6efab9c56f
28 changed files with 1158 additions and 477 deletions

View File

@@ -1 +1 @@
c26e82adc9e2fa295b554a1aa0818f7e99d89a38
827077b899b109dcb4b5eb57c8e999246f5d44b5

View File

@@ -20,7 +20,7 @@ std::unique_ptr<FiddleContext> FiddleContext::MakeGLPLS(FiddleContextOptions)
#endif
#define GLFW_INCLUDE_NONE
#include "GLFW/glfw3.h"
#include <GLFW/glfw3.h>
using namespace rive;
using namespace rive::gpu;

View File

@@ -9,8 +9,8 @@
#define GLFW_INCLUDE_NONE
#define GLFW_EXPOSE_NATIVE_COCOA
#include "GLFW/glfw3.h"
#include "GLFW/glfw3native.h"
#include <GLFW/glfw3.h>
#include <GLFW/glfw3native.h>
using namespace rive;
using namespace rive::gpu;

View File

@@ -4,8 +4,6 @@
#include "gradient.hpp"
#include "rive/renderer/rive_render_image.hpp"
namespace rive::gpu
{
// Ensure the given gradient stops are in a format expected by PLS.

View File

@@ -9,7 +9,11 @@
// Run a microbenchmark repeatedly for a few seconds and print the quickest
// time.
#ifdef __EMSCRIPTEN__
int rive_wasm_main(int argc, const char* const* argv)
#else
int main(int argc, const char** argv)
#endif
{
using namespace std::chrono_literals;
using clock = std::chrono::high_resolution_clock;
@@ -18,8 +22,8 @@ int main(int argc, const char** argv)
clock::duration benchDuration = 5s;
// Parse flags.
const char** arg = argv + 1;
const char** endarg = argv + argc;
const char* const* arg = argv + 1;
const char* const* endarg = argv + argc;
while (arg + 1 < endarg && *arg[0] == '-')
{
if (!strcmp(*arg, "--duration") || !strcmp(*arg, "-d"))

View File

@@ -53,6 +53,16 @@ while :; do
fi
shift
;;
-w)
TARGET="webbrowser"
DEFAULT_BACKEND=gl
shift
;;
-ws)
TARGET="webserver"
DEFAULT_BACKEND=gl
shift
;;
-R)
REBASELINE=true
shift
@@ -109,6 +119,8 @@ do
ID="iossim_$UDID/$BACKEND"
elif [[ "$TARGET" == "android" ]]; then
ID="android_$SERIAL/$BACKEND"
elif [[ "$TARGET" != "host" ]]; then
ID="$TARGET/$BACKEND"
fi
if [ "$REBASELINE" == true ]; then

View File

@@ -230,6 +230,8 @@ static void draw_thread(rcp<CommandQueue> commandQueue)
#ifdef RIVE_ANDROID
int rive_android_main(int argc, const char* const* argv)
#elif defined(__EMSCRIPTEN__)
int rive_wasm_main(int argc, const char* const* argv)
#else
int main(int argc, const char* argv[])
#endif

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2025 Rive
*/
#if defined(__EMSCRIPTEN__)
#include "common/rive_wasm_app.hpp"
#include "common/test_harness.hpp"
#include <emscripten/emscripten.h>
#include <emscripten/html5.h>
#include <sstream>
#include <vector>
EM_JS(char*, get_window_location, (), {
var jsString = window.location.href;
var lengthBytes = lengthBytesUTF8(jsString) + 1;
var stringOnWasmHeap = _malloc(lengthBytes);
stringToUTF8(jsString, stringOnWasmHeap, lengthBytes);
return stringOnWasmHeap;
});
EM_JS(char*, get_window_location_hash, (), {
var jsString = window.location.hash.substring(1);
var lengthBytes = lengthBytesUTF8(jsString) + 1;
var stringOnWasmHeap = _malloc(lengthBytes);
stringToUTF8(jsString, stringOnWasmHeap, lengthBytes);
return stringOnWasmHeap;
});
static void split_string(const std::string& str,
const std::string& delimiter,
std::vector<std::string>& result)
{
size_t start = 0;
size_t end;
while ((end = str.find(delimiter, start)) != std::string::npos)
{
if (end > start)
{
result.push_back(str.substr(start, end - start));
}
start = end + delimiter.length();
}
if (str.length() > start)
{
// Add the last piece
result.push_back(str.substr(start));
}
}
int main()
{
char* location = get_window_location();
char* hash = get_window_location_hash();
// Command line arguments are passed via the window location's hash string.
//
// e.g., "http://localhost/my_app.html#--backend%20gl%20--another%20arg"
//
std::vector<std::string> hashStrs = {location};
split_string(hash, "%20", hashStrs);
free(hash);
free(location);
std::vector<const char*> hashArgs;
for (const std::string& str : hashStrs)
{
hashArgs.push_back(str.c_str());
}
return rive_wasm_main(hashArgs.size(), hashArgs.data());
}
extern "C" void rive_print_message_on_server(const char* msg)
{
// stdout and stderr get redirected here and forwarded to the server for
// logging.
TestHarness::Instance().printMessageOnServer(msg);
}
#endif

View File

@@ -0,0 +1,9 @@
/*
* Copyright 2025 Rive
*/
#pragma once
// Primary entrypoint for rive wasm tests. Each individual test is expected
// to implement this method in wasm (instead of "main()").
extern int rive_wasm_main(int argc, const char* const* argv);

View File

@@ -5,6 +5,17 @@
#include "common/stacktrace.hpp"
#include <assert.h>
#if defined(NO_REDIRECT_OUTPUT) || defined(__EMSCRIPTEN__)
namespace stacktrace
{
void replace_signal_handlers(SignalFunc signalFunc,
ExitFunc atExitFunc) noexcept
{}
}; // namespace stacktrace
#else
#ifdef _WIN32
#include <Windows.h>
#include <dbghelp.h>
@@ -55,8 +66,6 @@ const char* strsignal(int signo)
}
#endif
#ifndef NO_REDIRECT_OUTPUT
namespace stacktrace
{
#if defined(_WIN32)
@@ -285,9 +294,5 @@ void replace_signal_handlers(SignalFunc inSignalFunc,
#endif // defined _WIN32
#else // NO_REDIRECT_OUTPUT is defined
void replace_signal_handlers(SignalFunc signalFunc,
ExitFunc atExitFunc) noexcept
{}
#endif // ifdef NO_REDIRECT_OUTPUT
};
}; // namespace stacktrace
#endif // defined(NO_REDIRECT_OUTPUT) || defined(__EMSCRIPTEN__)

View File

@@ -43,7 +43,10 @@ static bool is_socket_valid(SOCKET sockfd)
static void close_socket(SOCKET sockfd)
{
#ifdef _WIN32
#ifdef __EMSCRIPTEN__
constexpr static int WEBSOCKET_NORMAL_CLOSURE = 1000;
emscripten_websocket_close(sockfd, WEBSOCKET_NORMAL_CLOSURE, "finished");
#elif defined(_WIN32)
closesocket(sockfd);
#else
close(sockfd);
@@ -51,12 +54,10 @@ static void close_socket(SOCKET sockfd)
}
TCPClient::TCPClient(const char* serverAddress /*server:port*/, bool* success) :
m_serverAddress(serverAddress)
m_serverAddress(serverAddress), m_sockfd(invalid_socket())
{
*success = false;
m_sockfd = invalid_socket();
char hostname[256];
uint16_t port;
if (sscanf(serverAddress, "%255[^:]:%hu", hostname, &port) != 2)
@@ -64,13 +65,30 @@ TCPClient::TCPClient(const char* serverAddress /*server:port*/, bool* success) :
return;
}
#ifdef __EMSCRIPTEN__
// WASM has to use websockets instead of TCP.
if (!emscripten_websocket_is_supported())
{
fprintf(stderr,
"ERROR: WebSockets are not supported in this browser.\n");
abort();
}
auto url = std::string("ws://") + serverAddress;
EmscriptenWebSocketCreateAttributes attr = {
.url = url.c_str(),
.protocols = NULL,
.createOnMainThread = EM_TRUE,
};
m_sockfd = emscripten_websocket_new(&attr);
#else
#ifdef _WIN32
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
{
fprintf(stderr, "WSAStartup() failed.\n");
abort();
return;
}
#endif
@@ -79,6 +97,7 @@ TCPClient::TCPClient(const char* serverAddress /*server:port*/, bool* success) :
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(hostname);
m_sockfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
#endif
if (!is_socket_valid(m_sockfd))
{
@@ -90,6 +109,29 @@ TCPClient::TCPClient(const char* serverAddress /*server:port*/, bool* success) :
abort();
}
#ifdef __EMSCRIPTEN__
emscripten_websocket_set_onopen_callback(m_sockfd, this, OnWebSocketOpen);
emscripten_websocket_set_onmessage_callback(m_sockfd,
this,
OnWebSocketMessage);
emscripten_websocket_set_onerror_callback(m_sockfd, this, OnWebSocketError);
while (m_webSocketStatus != WebSocketStatus::open)
{
if (m_webSocketStatus == WebSocketStatus::error)
{
fprintf(stderr,
"Unable to connect to WebSocket at %s\n",
url.c_str());
abort();
}
// Busy wait until our connection is established.
//
// NOTE: emscripten_sleep() is an async operation that yields control
// back to the browser to process.
emscripten_sleep(10);
}
#else
if (connect(m_sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0)
{
fprintf(stderr,
@@ -99,6 +141,7 @@ TCPClient::TCPClient(const char* serverAddress /*server:port*/, bool* success) :
strerror(GetLastError()));
abort();
}
#endif
*success = true;
}
@@ -127,6 +170,16 @@ std::unique_ptr<TCPClient> TCPClient::clone() const
uint32_t TCPClient::send(const char* data, uint32_t size)
{
#ifdef __EMSCRIPTEN__
if (emscripten_websocket_send_binary(m_sockfd,
const_cast<char*>(data),
size) < 0)
{
fprintf(stderr, "Failed to send %u bytes to websocket.\n", size);
abort();
}
return size;
#else
size_t sent = ::send(m_sockfd, data, size, 0);
if (sent == -1)
{
@@ -134,12 +187,29 @@ uint32_t TCPClient::send(const char* data, uint32_t size)
abort();
}
return rive::math::lossless_numeric_cast<uint32_t>(sent);
#endif
}
uint32_t TCPClient::recv(char* buff, uint32_t size)
{
#ifdef __EMSCRIPTEN__
while (m_serverMessages.size() < size)
{
// Busy wait until the server sends us our data. This isn't ideal, but
// we always expect immediate responses with our testing protocol.
//
// NOTE: emscripten_sleep() is an async operation that yields control
// back to the browser to process.
emscripten_sleep(10);
}
std::copy(m_serverMessages.begin(), m_serverMessages.begin() + size, buff);
m_serverMessages.erase(m_serverMessages.begin(),
m_serverMessages.begin() + size);
return size;
#else
return rive::math::lossless_numeric_cast<uint32_t>(
::recv(m_sockfd, buff, size, 0));
#endif
}
void TCPClient::sendall(const void* data, size_t size)
@@ -208,4 +278,39 @@ std::string TCPClient::recvString()
recvall(str.data(), length);
return str;
}
#ifdef __EMSCRIPTEN__
EM_BOOL TCPClient::OnWebSocketOpen(int eventType,
const EmscriptenWebSocketOpenEvent* e,
void* userData)
{
auto this_ = reinterpret_cast<TCPClient*>(userData);
assert(e->socket == this_->m_sockfd);
this_->m_webSocketStatus = WebSocketStatus::open;
return EM_TRUE;
}
EM_BOOL TCPClient::OnWebSocketMessage(int eventType,
const EmscriptenWebSocketMessageEvent* e,
void* userData)
{
auto this_ = reinterpret_cast<TCPClient*>(userData);
assert(e->socket == this_->m_sockfd);
this_->m_serverMessages.insert(this_->m_serverMessages.end(),
e->data,
e->data + e->numBytes);
return EM_TRUE;
}
EM_BOOL TCPClient::OnWebSocketError(int eventType,
const EmscriptenWebSocketErrorEvent* e,
void* userData)
{
auto this_ = reinterpret_cast<TCPClient*>(userData);
assert(e->socket == this_->m_sockfd);
this_->m_webSocketStatus = WebSocketStatus::error;
return EM_TRUE;
}
#endif
#endif

View File

@@ -8,7 +8,12 @@
#include <string>
#include <stdint.h>
#ifdef _WIN32
#ifdef __EMSCRIPTEN__
#include <emscripten/websocket.h>
#include <arpa/inet.h>
#include <deque>
#define SOCKET EMSCRIPTEN_WEBSOCKET_T
#elif defined(_WIN32)
#include "WinSock2.h"
#else
#include <unistd.h>
@@ -64,4 +69,26 @@ private:
const std::string m_serverAddress;
SOCKET m_sockfd;
#ifdef __EMSCRIPTEN__
static EM_BOOL OnWebSocketOpen(int eventType,
const EmscriptenWebSocketOpenEvent* e,
void* userData);
static EM_BOOL OnWebSocketMessage(int eventType,
const EmscriptenWebSocketMessageEvent*,
void* userData);
static EM_BOOL OnWebSocketError(int eventType,
const EmscriptenWebSocketErrorEvent*,
void* userData);
enum class WebSocketStatus
{
pendingConnection,
open,
error,
};
WebSocketStatus m_webSocketStatus = WebSocketStatus::pendingConnection;
std::deque<uint8_t> m_serverMessages;
#endif
};

View File

@@ -25,9 +25,7 @@ constexpr static uint32_t REQUEST_TYPE_CLAIM_GM_TEST = 1;
constexpr static uint32_t REQUEST_TYPE_FETCH_RIV_FILE = 2;
constexpr static uint32_t REQUEST_TYPE_GET_INPUT = 3;
constexpr static uint32_t REQUEST_TYPE_CANCEL_INPUT = 4;
#ifndef NO_REDIRECT_OUTPUT
constexpr static uint32_t REQUEST_TYPE_PRINT_MESSAGE = 5;
#endif
constexpr static uint32_t REQUEST_TYPE_DISCONNECT = 6;
constexpr static uint32_t REQUEST_TYPE_APPLICATION_CRASH = 7;
@@ -66,10 +64,13 @@ void TestHarness::init(std::unique_ptr<TCPClient> tcpClient,
initStdioThread();
// We don't compile with emscripten pthreads.
#ifndef __EMSCRIPTEN__
for (size_t i = 0; i < pngThreadCount; ++i)
{
m_encodeThreads.emplace_back(EncodePNGThread, this);
}
#endif
}
void TestHarness::init(std::filesystem::path outputDir, size_t pngThreadCount)
@@ -92,8 +93,7 @@ void TestHarness::init(std::filesystem::path outputDir, size_t pngThreadCount)
void TestHarness::initStdioThread()
{
#ifndef NO_REDIRECT_OUTPUT
#if !defined(NO_REDIRECT_OUTPUT) && !defined(__EMSCRIPTEN__)
#ifndef _WIN32
// Make stdout & stderr line buffered. (This is not supported on Windows.)
setvbuf(stdout, NULL, _IOLBF, 0);
@@ -111,7 +111,7 @@ void TestHarness::initStdioThread()
void TestHarness::monitorStdIOThread()
{
#ifndef NO_REDIRECT_OUTPUT
#if !defined(NO_REDIRECT_OUTPUT) && !defined(__EMSCRIPTEN__)
assert(m_initialized);
std::unique_ptr<TCPClient> threadTCPClient;
@@ -163,6 +163,124 @@ void send_png_data_chunk(png_structp png, png_bytep data, png_size_t length)
void flush_png_data(png_structp png) {}
static void save_png_impl(ImageSaveArgs args,
const std::filesystem::path& outputDir,
PNGCompression pngCompression,
TCPClient* tcpClient)
{
assert(args.width > 0);
assert(args.height > 0);
std::string pngName = args.name + ".png";
if (tcpClient == nullptr)
{
// We aren't connect to a test harness. Just save a file.
auto destination = outputDir;
destination /= pngName;
destination.make_preferred();
WritePNGFile(args.pixels.data(),
args.width,
args.height,
true,
destination.generic_string().c_str(),
pngCompression);
return;
}
tcpClient->send4(REQUEST_TYPE_IMAGE_UPLOAD);
tcpClient->sendString(pngName);
auto png = png_create_write_struct(PNG_LIBPNG_VER_STRING, NULL, NULL, NULL);
if (!png)
{
fprintf(stderr, "TestHarness: png_create_write_struct failed\n");
abort();
}
// RLE with SUB gets best performance with our content.
png_set_compression_level(png, 6);
png_set_compression_strategy(png, Z_RLE);
png_set_compression_strategy(png, Z_RLE);
png_set_filter(png, 0, PNG_FILTER_SUB);
auto info = png_create_info_struct(png);
if (!info)
{
fprintf(stderr, "TestHarness: png_create_info_struct failed\n");
abort();
}
if (setjmp(png_jmpbuf(png)))
{
fprintf(stderr, "TestHarness: Error during init_io\n");
abort();
}
png_set_write_fn(png, tcpClient, &send_png_data_chunk, &flush_png_data);
// Write header.
if (setjmp(png_jmpbuf(png)))
{
fprintf(stderr, "TestHarness: Error during writing header\n");
abort();
}
png_set_IHDR(png,
info,
args.width,
args.height,
8,
PNG_COLOR_TYPE_RGB_ALPHA,
PNG_INTERLACE_NONE,
PNG_COMPRESSION_TYPE_BASE,
PNG_FILTER_TYPE_BASE);
png_write_info(png, info);
// Write bytes.
if (setjmp(png_jmpbuf(png)))
{
fprintf(stderr, "TestHarness: Error during writing bytes\n");
abort();
}
std::vector<uint8_t*> rows(args.height);
for (uint32_t y = 0; y < args.height; ++y)
{
rows[y] = args.pixels.data() + (args.height - 1 - y) * args.width * 4;
}
png_write_image(png, rows.data());
// End write.
if (setjmp(png_jmpbuf(png)))
{
fprintf(stderr, "TestHarness: Error during end of write");
abort();
}
png_write_end(png, NULL);
png_destroy_write_struct(&png, &info);
tcpClient->sendHandshake();
tcpClient->recvHandshake();
}
void TestHarness::savePNG(ImageSaveArgs args)
{
assert(m_initialized);
if (!m_encodeThreads.empty())
{
m_encodeQueue.push(std::move(args));
}
else
{
save_png_impl(std::move(args),
m_outputDir,
m_pngCompression,
m_primaryTCPClient.get());
}
}
void TestHarness::encodePNGThread()
{
assert(m_initialized);
@@ -181,106 +299,10 @@ void TestHarness::encodePNGThread()
{
break;
}
assert(args.width > 0);
assert(args.height > 0);
std::string pngName = args.name + ".png";
if (threadTCPClient == nullptr)
{
// We aren't connect to a test harness. Just save a file.
auto destination = m_outputDir;
destination /= pngName;
destination.make_preferred();
WritePNGFile(args.pixels.data(),
args.width,
args.height,
true,
destination.generic_string().c_str(),
m_pngCompression);
continue;
}
threadTCPClient->send4(REQUEST_TYPE_IMAGE_UPLOAD);
threadTCPClient->sendString(pngName);
auto png =
png_create_write_struct(PNG_LIBPNG_VER_STRING, NULL, NULL, NULL);
if (!png)
{
fprintf(stderr, "TestHarness: png_create_write_struct failed\n");
abort();
}
// RLE with SUB gets best performance with our content.
png_set_compression_level(png, 6);
png_set_compression_strategy(png, Z_RLE);
png_set_compression_strategy(png, Z_RLE);
png_set_filter(png, 0, PNG_FILTER_SUB);
auto info = png_create_info_struct(png);
if (!info)
{
fprintf(stderr, "TestHarness: png_create_info_struct failed\n");
abort();
}
if (setjmp(png_jmpbuf(png)))
{
fprintf(stderr, "TestHarness: Error during init_io\n");
abort();
}
png_set_write_fn(png,
threadTCPClient.get(),
&send_png_data_chunk,
&flush_png_data);
// Write header.
if (setjmp(png_jmpbuf(png)))
{
fprintf(stderr, "TestHarness: Error during writing header\n");
abort();
}
png_set_IHDR(png,
info,
args.width,
args.height,
8,
PNG_COLOR_TYPE_RGB_ALPHA,
PNG_INTERLACE_NONE,
PNG_COMPRESSION_TYPE_BASE,
PNG_FILTER_TYPE_BASE);
png_write_info(png, info);
// Write bytes.
if (setjmp(png_jmpbuf(png)))
{
fprintf(stderr, "TestHarness: Error during writing bytes\n");
abort();
}
std::vector<uint8_t*> rows(args.height);
for (uint32_t y = 0; y < args.height; ++y)
{
rows[y] =
args.pixels.data() + (args.height - 1 - y) * args.width * 4;
}
png_write_image(png, rows.data());
// End write.
if (setjmp(png_jmpbuf(png)))
{
fprintf(stderr, "TestHarness: Error during end of write");
abort();
}
png_write_end(png, NULL);
png_destroy_write_struct(&png, &info);
threadTCPClient->sendHandshake();
threadTCPClient->recvHandshake();
save_png_impl(std::move(args),
m_outputDir,
m_pngCompression,
threadTCPClient.get());
}
if (threadTCPClient != nullptr)
@@ -321,6 +343,15 @@ bool TestHarness::fetchRivFile(std::string& name, std::vector<uint8_t>& bytes)
return true;
}
void TestHarness::printMessageOnServer(const char* msg)
{
if (m_primaryTCPClient != nullptr)
{
m_primaryTCPClient->send4(REQUEST_TYPE_PRINT_MESSAGE);
m_primaryTCPClient->sendString(msg);
}
}
void TestHarness::inputPumpThread()
{
assert(m_initialized);
@@ -354,6 +385,10 @@ void TestHarness::inputPumpThread()
bool TestHarness::peekChar(char& key)
{
#ifdef __EMSCRIPTEN__
// We don't compile with emscripten pthreads.
return false;
#endif
if (m_primaryTCPClient == nullptr)
{
return false;
@@ -397,7 +432,7 @@ void TestHarness::shutdown()
void TestHarness::shutdownStdioThread()
{
#ifndef NO_REDIRECT_OUTPUT
#if !defined(NO_REDIRECT_OUTPUT) && !defined(__EMSCRIPTEN__)
if (m_savedStdout != 0 || m_savedStderr != 0)
{
// Restore stdout and stderr.

View File

@@ -43,14 +43,7 @@ public:
m_pngCompression = compression;
}
void savePNG(ImageSaveArgs args)
{
assert(m_initialized);
if (!m_encodeThreads.empty())
{
m_encodeQueue.push(std::move(args));
}
}
void savePNG(ImageSaveArgs args);
// Only returns true the on the first server request for a given name.
// Prevents gms from running more than once in a multi-process execution.
@@ -60,6 +53,10 @@ public:
// thread.)
bool fetchRivFile(std::string& name, std::vector<uint8_t>& bytes);
// Sends a message to be printed on the server's stdout, e.g., for
// forwarding the client's stdout.
void printMessageOnServer(const char* msg);
// Returns true if there is an input character to process from the server.
bool peekChar(char& key);

View File

@@ -4,7 +4,7 @@
#include "testing_window.hpp"
#ifdef RIVE_TOOLS_NO_GL
#if defined(RIVE_TOOLS_NO_GL) || defined(__EMSCRIPTEN__)
TestingWindow* TestingWindow::MakeEGL(Backend backend,
const BackendParams& backendParams,

View File

@@ -21,9 +21,7 @@ TestingWindow* TestingWindow::MakeFiddleContext(Backend,
#include <queue>
#define GLFW_INCLUDE_NONE
#define GLFW_NATIVE_INCLUDE_NONE
#include "GLFW/glfw3.h"
#include <GLFW/glfw3native.h>
#include <GLFW/glfw3.h>
using namespace rive;
using namespace rive::gpu;
@@ -142,7 +140,7 @@ public:
#elif defined(_WIN32)
glfwInitHint(GLFW_ANGLE_PLATFORM_TYPE,
GLFW_ANGLE_PLATFORM_TYPE_D3D11);
#else
#elif !defined(__EMSCRIPTEN__)
glfwInitHint(GLFW_ANGLE_PLATFORM_TYPE,
GLFW_ANGLE_PLATFORM_TYPE_VULKAN);
#endif
@@ -227,7 +225,9 @@ public:
abort();
}
glfwMakeContextCurrent(m_glfwWindow);
#ifndef __EMSCRIPTEN__
glfwSwapInterval(0);
#endif
glfwSetKeyCallback(m_glfwWindow, key_callback);
glfwSetCursorPosCallback(m_glfwWindow, mouse_position_callback);

View File

@@ -3,6 +3,7 @@
import argparse
import atexit
import glob
import http.server
import os
import platform
import queue
@@ -65,7 +66,8 @@ parser.add_argument("-m", "--match",
help="`match` patter for gms")
parser.add_argument("-t", "--target",
default="host",
choices=["host", "android", "ios", "iossim", "unreal", "unreal_android"],
choices=["host", "android", "ios", "iossim", "unreal",
"unreal_android", "webbrowser", "webserver"],
help="which platform to run on")
parser.add_argument("-a", "--android-arch",
default="arm64",
@@ -74,6 +76,9 @@ parser.add_argument("-u", "--ios_udid",
type=str,
default=None,
help="unique id of iOS device to run on (--target=ios or iossim)")
parser.add_argument("-c", "--webclient",
default=None,
help="executable to launch when --target=webserver")
parser.add_argument("-k", "--options",
type=str,
default=None,
@@ -171,6 +176,99 @@ def get_local_ip():
s.close()
return ip
# Simple http server for web-based targets.
def start_http_server(directory, server_ip):
import functools
import http.server
http_port_holder = []
http_port_ready_event = threading.Event()
class COOPHandler(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
# Serve cross-origin isolated pages so SharedArrayBuffer is defined
# for emscripten POSIX emulation.
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
super().end_headers()
def log_message(self, format, *args):
# Suppress default HTTP logs like:
# 127.0.0.1 - - [22/Aug/2025 13:37:00] "GET /index.html HTTP/1.1" 200 -
pass
handler = functools.partial(COOPHandler, directory=directory)
def run_http_server():
with socketserver.TCPServer((server_ip, 0), handler) as httpd:
http_port_holder.append(httpd.server_address[1])
http_port_ready_event.set()
httpd.serve_forever()
thread = threading.Thread(target=run_http_server, daemon=True)
thread.start()
http_port_ready_event.wait() # wait until server is ready
return (server_ip, http_port_holder[0])
# Simple websocket <-> TCP bridge for web-based targets.
def start_websocket_bridge(tcp_server_address):
import asyncio
import threading
import socket
import websockets
websocket_port_holder = []
port_ready_event = threading.Event()
async def handle_websocket(websocket):
reader, writer = await asyncio.open_connection(*tcp_server_address)
async def tcp_to_ws():
try:
while True:
data = await reader.read(1024)
if not data:
break
await websocket.send(data)
except:
raise
async def ws_to_tcp():
try:
async for message in websocket:
if isinstance(message, str):
message = message.encode()
writer.write(message)
await writer.drain()
except:
raise
await asyncio.gather(tcp_to_ws(), ws_to_tcp())
writer.close()
await writer.wait_closed()
async def run_server(sock):
async with websockets.serve(handle_websocket, sock=sock):
await asyncio.Future() # run forever
def server_thread():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('0.0.0.0', 0)) # OS assigns port
sock.listen(5)
sock.setblocking(False)
websocket_port_holder.append(sock.getsockname()[1])
port_ready_event.set()
asyncio.run(run_server(sock))
thread = threading.Thread(target=server_thread, daemon=True)
thread.start()
port_ready_event.wait()
return (tcp_server_address[0], websocket_port_holder[0])
# Simple TCP server for Rive tools.
class ToolServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
daemon_threads = True
@@ -207,6 +305,19 @@ class ToolServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
hostname, port = self.server_address # find out what port we were given
subprocess.Popen(["adb", "reverse", "tcp:%s" % port, "tcp:%s" % port],
stdout=subprocess.DEVNULL)
print("TestHarness server running on %s:%u" % self.server_address, flush=True)
if args.target.startswith("web"):
self.server_address = start_websocket_bridge(self.server_address)
print("TestHarness server bridged to WebSocket on %s:%u" % self.server_address,
flush=True)
self.http_address = start_http_server(args.builddir,
self.server_address[0])
print("HTTP server running from %s on %s:%u" % (args.builddir,
*self.http_address),
flush=True)
# Simple utility to wait until a TCP client tells the server it has finished.
def wait_for_shutdown_event(self, timeout=threading.TIMEOUT_MAX):
@@ -345,7 +456,7 @@ class TestHarnessRequestHandler(socketserver.BaseRequestHandler):
# If we aren't deploying to the host, update the given command to deploy on its intended target.
def update_cmd_to_deploy_on_target(cmd):
def update_cmd_to_deploy_on_target(cmd, test_harness_server):
dirname = os.path.dirname(cmd[0])
toolname = os.path.basename(cmd[0])
@@ -389,6 +500,16 @@ def update_cmd_to_deploy_on_target(cmd):
cmd = [toolname] + cmd[1:]
return ["xcrun", "simctl", "launch", args.ios_udid, "rive.app.golden-test-app"] + cmd
elif args.target.startswith("web"):
if args.target == "webbrowser":
client = ["python3", "-m", "webbrowser", "-t"]
elif args.webclient:
client = [args.webclient]
else:
client = ["echo", "\nPlease navigate your web client to:\n\n"]
return client + ["http://%s:%u/%s.html#%s" % (*test_harness_server.http_address,
toolname,
'%20'.join(cmd[1:]))]
else:
assert(args.target == "host")
return cmd
@@ -399,14 +520,15 @@ def launch_gms(test_harness_server):
tool = tool + ".exe"
cmd = [tool,
"--backend", args.backend,
"--test_harness", "%s:%u" % test_harness_server.server_address,
"--headless",
"-p%i" % args.png_threads]
"--test_harness", "%s:%u" % test_harness_server.server_address]
if not args.target.startswith("web"):
cmd = cmd + ["--headless",
"-p%i" % args.png_threads];
if args.match:
cmd = cmd + ["--match", args.match];
if args.verbose:
cmd = cmd + ["--verbose"];
cmd = update_cmd_to_deploy_on_target(cmd)
cmd = update_cmd_to_deploy_on_target(cmd, test_harness_server)
procs = [CheckProcess(cmd) for i in range(0, args.jobs_per_tool)]
for proc in procs:
@@ -436,12 +558,13 @@ def launch_goldens(test_harness_server):
"--test_harness", "%s:%u" % test_harness_server.server_address,
"--backend", args.backend,
"--rows", str(args.rows),
"--cols", str(args.cols),
"--headless",
"-p%i" % args.png_threads]
"--cols", str(args.cols)]
if not args.target.startswith("web"):
cmd = cmd + ["--headless",
"-p%i" % args.png_threads];
if args.verbose:
cmd = cmd + ["--verbose"];
cmd = update_cmd_to_deploy_on_target(cmd)
cmd = update_cmd_to_deploy_on_target(cmd, test_harness_server)
procs = [CheckProcess(cmd) for i in range(0, args.jobs_per_tool)]
for proc in procs:
@@ -462,7 +585,7 @@ def launch_player(test_harness_server):
"--backend", args.backend]
if args.options:
cmd += ["--options", args.options]
cmd = update_cmd_to_deploy_on_target(cmd)
cmd = update_cmd_to_deploy_on_target(cmd, test_harness_server)
rivsqueue.put(args.src)
player = CheckProcess(cmd)
@@ -527,6 +650,12 @@ def main():
args.builddir = os.path.join("out", "debug")
# unreal is currently always rhi, we may have seperate rhi types in the future like rhi_metal etc..
args.backend = 'rhi'
elif args.target.startswith("web"):
args.jobs_per_tool = 1
if args.builddir == None:
args.builddir = f"out/wasm_debug"
if args.backend == None:
args.backend = "gl"
else:
assert(args.target == "host")
if args.builddir == None:
@@ -670,11 +799,12 @@ def main():
with (ToolServer(TestHarnessRequestHandler) as test_harness_server):
test_harness_server.serve_forever_async()
print("TestHarness server running on %s:%u" % test_harness_server.server_address,
flush=True)
# On mobile we can't launch >1 instance of the app at a time.
serial_deploy = not args.server_only and ("ios" in args.target or args.target == "android" or args.target == "unreal")
# On many targets we can't launch >1 instance of the app at a time.
serial_deploy = not args.server_only and ("ios" in args.target or
args.target == "android" or
"unreal" in args.target or
args.target.startswith("web"))
procs = []
def keyboard_interrupt_handler(signal, frame):

View File

@@ -14,6 +14,11 @@
#include <sys/system_properties.h>
#endif
#ifdef __EMSCRIPTEN__
#include "common/rive_wasm_app.hpp"
#include <emscripten/emscripten.h>
#endif
#include <functional>
using namespace rivegm;
@@ -220,6 +225,10 @@ static void dumpGMs(const std::string& match, bool interactive)
{
return;
}
#endif
#ifdef __EMSCRIPTEN__
// Yield control back to the browser so it can process its event loop.
emscripten_sleep(1);
#endif
}
}
@@ -307,6 +316,8 @@ extern "C" int gms_main(int argc, const char* argv[])
int gms_ios_main(int argc, const char* argv[])
#elif defined(RIVE_ANDROID)
int rive_android_main(int argc, const char* const* argv)
#elif defined(__EMSCRIPTEN__)
int rive_wasm_main(int argc, const char* const* argv)
#else
int main(int argc, const char* argv[])
#endif
@@ -415,6 +426,9 @@ int main(int argc, const char* argv[])
TestingWindow::Destroy(); // Exercise our PLS teardown process now that
// we're done.
TestHarness::Instance().shutdown();
#ifdef __EMSCRIPTEN__
EM_ASM(window.close(););
#endif
return 0;
}

23
tests/gm/gms.html Normal file
View File

@@ -0,0 +1,23 @@
<canvas id="canvas"></canvas>
<script>
window.onhashchange = function() { location.reload(); }
function printMessageOnServer(text) {
const lengthBytes = lengthBytesUTF8(text) + 1;
const stringOnWasmHeap = Module['_malloc'](lengthBytes);
stringToUTF8(text, stringOnWasmHeap, lengthBytes);
Module['_rive_print_message_on_server'](stringOnWasmHeap);
Module['_free'](stringOnWasmHeap);
}
var Module = {
'canvas': document.getElementById("canvas"),
'print': function(text) {
printMessageOnServer(text + '\n');
console.log(text);
},
'printErr': function(text) {
printMessageOnServer(text + '\n');
console.error(text);
},
};
</script>
<script src="gms.js"></script>

View File

@@ -0,0 +1,15 @@
<!--
Experiment to run the tests in multiple popup windows.
No perf improvement has been observed.
-->
<script>
for (let i = 0; i < 4; ++i) {
const x = (i % 2) * 450;
const y = Math.floor(i / 2) * 550;
window.open(
`gms.html${window.location.hash}`,
`gms_${i + 1}`,
`width=${400},height=${400},top=${y},left=${x},toolbar=no,menubar=no,scrollbars=no,resizable=no,location=no,status=no`
);
}
</script>

View File

@@ -24,19 +24,24 @@ public:
void onDraw(rive::Renderer* renderer) override
{
Rand rand;
ColorInt colors[N];
float stops[N];
std::vector<ColorInt> colors(N);
std::vector<float> stops(N);
for (size_t i = 0; i < N; ++i)
{
colors[i] = rand.u32() | 0xff808080;
stops[i] = std::round(rand.f32() * W) / W;
}
std::sort(stops, stops + N);
std::sort(stops.begin(), stops.end());
Paint paint;
paint->shader(TestingWindow::Get()
->factory()
->makeLinearGradient(0, 0, W, 0, colors, stops, N));
paint->shader(
TestingWindow::Get()->factory()->makeLinearGradient(0,
0,
W,
0,
colors.data(),
stops.data(),
N));
Path fullscreen = PathBuilder().addRect({0, 0, W, H}).detach();

View File

@@ -24,6 +24,11 @@
#include "common/rive_android_app.hpp"
#endif
#ifdef __EMSCRIPTEN__
#include "common/rive_wasm_app.hpp"
#include <emscripten/emscripten.h>
#endif
constexpr static int kWindowTargetSize = 1600;
GoldensArguments s_args;
@@ -124,6 +129,10 @@ static bool render_and_dump_png(int cellSize,
{
return false;
}
#endif
#ifdef __EMSCRIPTEN__
// Yield control back to the browser so it can process its event loop.
emscripten_sleep(1);
#endif
}
catch (const char* msg)
@@ -224,6 +233,8 @@ int goldens_main(int argc, const char* argv[])
int goldens_ios_main(int argc, const char* argv[])
#elif defined(RIVE_ANDROID)
int rive_android_main(int argc, const char* const* argv)
#elif defined(__EMSCRIPTEN__)
int rive_wasm_main(int argc, const char* const* argv)
#else
int main(int argc, const char* argv[])
#endif
@@ -366,6 +377,9 @@ int main(int argc, const char* argv[])
TestingWindow::Destroy(); // Exercise our PLS teardown process now that
// we're done.
TestHarness::Instance().shutdown();
#ifdef __EMSCRIPTEN__
EM_ASM(window.close(););
#endif
return 0;
}

View File

@@ -0,0 +1,23 @@
<canvas id="canvas"></canvas>
<script>
window.onhashchange = function() { location.reload(); }
function printMessageOnServer(text) {
const lengthBytes = lengthBytesUTF8(text) + 1;
const stringOnWasmHeap = Module['_malloc'](lengthBytes);
stringToUTF8(text, stringOnWasmHeap, lengthBytes);
Module['_rive_print_message_on_server'](stringOnWasmHeap);
Module['_free'](stringOnWasmHeap);
}
var Module = {
'canvas': document.getElementById("canvas"),
'print': function(text) {
printMessageOnServer(text + '\n');
console.log(text);
},
'printErr': function(text) {
printMessageOnServer(text + '\n');
console.error(text);
},
};
</script>
<script src="goldens.js"></script>

View File

@@ -0,0 +1,15 @@
<!--
Experiment to run the tests in multiple popup windows.
No perf improvement has been observed.
-->
<script>
for (let i = 0; i < 4; ++i) {
const x = (i % 2) * 450;
const y = Math.floor(i / 2) * 550;
window.open(
`goldens.html${window.location.hash}`,
`goldens_${i + 1}`,
`width=${400},height=${400},top=${y},left=${x},toolbar=no,menubar=no,scrollbars=no,resizable=no,location=no,status=no`
);
}
</script>

View File

@@ -20,6 +20,12 @@
#include "common/rive_android_app.hpp"
#endif
#ifdef __EMSCRIPTEN__
#include "common/rive_wasm_app.hpp"
#include <emscripten/emscripten.h>
#include <emscripten/html5.h>
#endif
static void update_parameter(int& val, int multiplier, char key, bool seenBang)
{
if (seenBang)
@@ -134,10 +140,278 @@ static void key_pressed(char key)
seenBang = false;
}
class Player
{
public:
void init(std::string rivName, std::vector<uint8_t> rivBytes)
{
m_rivName = std::move(rivName);
m_file = rive::File::import(rivBytes, TestingWindow::Get()->factory());
assert(m_file);
m_artboard = m_file->artboardDefault();
assert(m_artboard);
m_scene = m_artboard->defaultStateMachine();
if (!m_scene)
{
m_scene = m_artboard->animationAt(0);
}
assert(m_scene);
// Setup FPS.
m_roboto = HBFont::Decode(assets::roboto_flex_ttf());
m_blackStroke = TestingWindow::Get()->factory()->makeRenderPaint();
m_blackStroke->color(0xff000000);
m_blackStroke->style(rive::RenderPaintStyle::stroke);
m_blackStroke->thickness(4);
m_whiteFill = TestingWindow::Get()->factory()->makeRenderPaint();
m_whiteFill->color(0xffffffff);
m_timeLastFPSUpdate = std::chrono::high_resolution_clock::now();
m_timestampPrevFrame = std::chrono::high_resolution_clock::now();
}
void doFrame()
{
if (quit || TestingWindow::Get()->shouldQuit()
#ifdef RIVE_ANDROID
|| !rive_android_app_poll_once()
#endif
)
{
printf("\nShutting down\n");
TestingWindow::Destroy(); // Exercise our PLS teardown process now
// that we're done.
TestHarness::Instance().shutdown();
#ifdef __EMSCRIPTEN__
emscripten_cancel_main_loop();
EM_ASM(window.close(););
#else
exit(0);
#endif
return;
}
#ifdef __EMSCRIPTEN__
{
// Fit the canvas to the browser window size.
int windowWidth = EM_ASM_INT(return window["innerWidth"]);
int windowHeight = EM_ASM_INT(return window["innerHeight"]);
double devicePixelRatio = emscripten_get_device_pixel_ratio();
int canvasExpectedWidth = windowWidth * devicePixelRatio;
int canvasExpectedHeight = windowHeight * devicePixelRatio;
if (TestingWindow::Get()->width() != canvasExpectedWidth ||
TestingWindow::Get()->height() != canvasExpectedHeight)
{
printf("Resizing HTML canvas to %i x %i.\n",
canvasExpectedWidth,
canvasExpectedHeight);
TestingWindow::Get()->resize(canvasExpectedWidth,
canvasExpectedHeight);
emscripten_set_element_css_size("#canvas",
windowWidth,
windowHeight);
}
}
#endif
std::chrono::time_point timeNow =
std::chrono::high_resolution_clock::now();
const double elapsedS =
std::chrono::duration_cast<std::chrono::nanoseconds>(
timeNow - m_timestampPrevFrame)
.count() /
1e9; // convert to s
m_timestampPrevFrame = timeNow;
float advanceDeltaTime = static_cast<float>(elapsedS);
if (forceFixedDeltaTime)
{
advanceDeltaTime = 1.0f / 120;
}
m_scene->advanceAndApply(paused ? 0 : advanceDeltaTime);
copiesLeft = std::max(copiesLeft, 0);
copiesAbove = std::max(copiesAbove, 0);
copiesRight = std::max(copiesRight, 0);
copiesBelow = std::max(copiesBelow, 0);
int copyCount =
(copiesLeft + 1 + copiesRight) * (copiesAbove + 1 + copiesBelow);
if (copyCount != lastReportedCopyCount ||
paused != lastReportedPauseState)
{
printf("Drawing %i copies of %s%s at %u x %u\n",
copyCount,
m_rivName.c_str(),
paused ? " (paused)" : "",
TestingWindow::Get()->width(),
TestingWindow::Get()->height());
lastReportedCopyCount = copyCount;
lastReportedPauseState = paused;
}
auto renderer = TestingWindow::Get()->beginFrame({
.clearColor = 0xff303030,
.doClear = true,
.wireframe = wireframe,
});
if (hotloadShaders)
{
hotloadShaders = false;
#ifndef RIVE_NO_STD_SYSTEM
std::system("sh rebuild_shaders.sh /tmp/rive");
TestingWindow::Get()->hotloadShaders();
#endif
}
renderer->save();
uint32_t width = TestingWindow::Get()->width();
uint32_t height = TestingWindow::Get()->height();
for (int i = rotations90; (i & 3) != 0; --i)
{
renderer->transform(rive::Mat2D(0, 1, -1, 0, width, 0));
std::swap(height, width);
}
if (zoomLevel != 0)
{
float scale = powf(1.25f, zoomLevel);
renderer->translate(width / 2.f, height / 2.f);
renderer->scale(scale, scale);
renderer->translate(width / -2.f, height / -2.f);
}
// Draw the .riv.
renderer->save();
renderer->align(rive::Fit::contain,
rive::Alignment::center,
rive::AABB(0, 0, width, height),
m_artboard->bounds());
float spacingPx = spacing * 5 + 150;
renderer->translate(-spacingPx * copiesLeft, -spacingPx * copiesAbove);
for (int y = -copiesAbove; y <= copiesBelow; ++y)
{
renderer->save();
for (int x = -copiesLeft; x <= copiesRight; ++x)
{
m_artboard->draw(renderer.get());
renderer->translate(spacingPx, 0);
}
renderer->restore();
renderer->translate(0, spacingPx);
}
renderer->restore();
if (m_fpsText != nullptr)
{
// Draw FPS.
renderer->save();
renderer->translate(0, 20);
m_fpsText->render(renderer.get(), m_blackStroke);
m_fpsText->render(renderer.get(), m_whiteFill);
renderer->restore();
}
renderer->restore();
TestingWindow::Get()->endFrame();
// Count FPS.
++m_fpsFrames;
const double elapsedFPSUpdate =
std::chrono::duration_cast<std::chrono::nanoseconds>(
timeNow - m_timeLastFPSUpdate)
.count() /
1e9; // convert to s
if (elapsedFPSUpdate >= 2.0)
{
double fps = m_fpsFrames / elapsedFPSUpdate;
printf("[%.3f FPS]\n", fps);
char fpsRawText[32];
snprintf(fpsRawText, sizeof(fpsRawText), " %.1f FPS ", fps);
m_fpsText = std::make_unique<rive::RawText>(
TestingWindow::Get()->factory());
m_fpsText->maxWidth(width);
#ifdef RIVE_ANDROID
m_fpsText->align(rive::TextAlign::center);
#else
m_fpsText->align(rive::TextAlign::right);
#endif
m_fpsText->sizing(rive::TextSizing::fixed);
m_fpsText->append(fpsRawText, nullptr, m_roboto, 50.f);
m_fpsFrames = 0;
m_timeLastFPSUpdate = timeNow;
}
const rive::Mat2D alignmentMat =
computeAlignment(rive::Fit::contain,
rive::Alignment::center,
rive::AABB(0, 0, width, height),
m_artboard->bounds());
// Consume all input events until none are left in the queue
TestingWindow::InputEventData inputEventData;
while (TestingWindow::Get()->consumeInputEvent(inputEventData))
{
const rive::Vec2D mousePosAligned =
alignmentMat.invertOrIdentity() *
rive::Vec2D(inputEventData.metadata.posX,
inputEventData.metadata.posY);
switch (inputEventData.eventType)
{
case TestingWindow::InputEvent::KeyPress:
key_pressed(inputEventData.metadata.key);
break;
case TestingWindow::InputEvent::MouseMove:
m_scene->pointerMove(mousePosAligned);
break;
case TestingWindow::InputEvent::MouseDown:
m_scene->pointerDown(mousePosAligned);
break;
case TestingWindow::InputEvent::MouseUp:
m_scene->pointerUp(mousePosAligned);
break;
}
}
char key;
while (TestHarness::Instance().peekChar(key))
{
key_pressed(key);
}
}
private:
std::string m_rivName;
rive::rcp<rive::File> m_file;
std::unique_ptr<rive::ArtboardInstance> m_artboard;
std::unique_ptr<rive::Scene> m_scene;
int lastReportedCopyCount = 0;
bool lastReportedPauseState = paused;
rive::rcp<rive::Font> m_roboto;
rive::rcp<rive::RenderPaint> m_blackStroke;
rive::rcp<rive::RenderPaint> m_whiteFill;
std::unique_ptr<rive::RawText> m_fpsText;
int m_fpsFrames = 0;
std::chrono::high_resolution_clock::time_point m_timeLastFPSUpdate;
std::chrono::high_resolution_clock::time_point m_timestampPrevFrame;
};
static Player player;
#if defined(RIVE_IOS) || defined(RIVE_IOS_SIMULATOR)
int player_ios_main(int argc, const char* argv[])
#elif defined(RIVE_ANDROID)
int rive_android_main(int argc, const char* const* argv)
#elif defined(__EMSCRIPTEN__)
int rive_wasm_main(int argc, const char* const* argv)
#else
int main(int argc, const char* argv[])
#endif
@@ -147,6 +421,7 @@ int main(int argc, const char* argv[])
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
#endif
std::string rivName;
std::vector<uint8_t> rivBytes;
auto backend =
@@ -157,6 +432,7 @@ int main(int argc, const char* argv[])
#endif
auto visibility = TestingWindow::Visibility::fullscreen;
TestingWindow::BackendParams backendParams;
for (int i = 0; i < argc; ++i)
{
if (strcmp(argv[i], "--test_harness") == 0)
@@ -224,230 +500,23 @@ int main(int argc, const char* argv[])
#endif
);
// Load the riv file.
if (rivBytes.empty())
{
fprintf(stderr, "no .riv file specified");
abort();
}
rive::rcp<rive::File> file =
rive::File::import(rivBytes, TestingWindow::Get()->factory());
assert(file);
std::unique_ptr<rive::ArtboardInstance> artboard = file->artboardDefault();
assert(artboard);
std::unique_ptr<rive::Scene> scene = artboard->defaultStateMachine();
if (!scene)
{
scene = artboard->animationAt(0);
}
assert(scene);
int lastReportedCopyCount = 0;
bool lastReportedPauseState = paused;
player.init(std::move(rivName), std::move(rivBytes));
// Setup FPS.
rive::rcp<rive::Font> roboto = HBFont::Decode(assets::roboto_flex_ttf());
rive::rcp<rive::RenderPaint> blackStroke =
TestingWindow::Get()->factory()->makeRenderPaint();
blackStroke->color(0xff000000);
blackStroke->style(rive::RenderPaintStyle::stroke);
blackStroke->thickness(4);
rive::rcp<rive::RenderPaint> whiteFill =
TestingWindow::Get()->factory()->makeRenderPaint();
whiteFill->color(0xffffffff);
std::unique_ptr<rive::RawText> fpsText;
int fpsFrames = 0;
std::chrono::time_point timeLastFPSUpdate =
std::chrono::high_resolution_clock::now();
std::chrono::time_point timestampPrevFrame =
std::chrono::high_resolution_clock::now();
while (!quit && !TestingWindow::Get()->shouldQuit())
{
std::chrono::time_point timeNow =
std::chrono::high_resolution_clock::now();
const double elapsedS =
std::chrono::duration_cast<std::chrono::nanoseconds>(
timeNow - timestampPrevFrame)
.count() /
1e9; // convert to s
timestampPrevFrame = timeNow;
float advanceDeltaTime = static_cast<float>(elapsedS);
if (forceFixedDeltaTime)
{
advanceDeltaTime = 1.0f / 120;
}
scene->advanceAndApply(paused ? 0 : advanceDeltaTime);
copiesLeft = std::max(copiesLeft, 0);
copiesAbove = std::max(copiesAbove, 0);
copiesRight = std::max(copiesRight, 0);
copiesBelow = std::max(copiesBelow, 0);
int copyCount =
(copiesLeft + 1 + copiesRight) * (copiesAbove + 1 + copiesBelow);
if (copyCount != lastReportedCopyCount ||
paused != lastReportedPauseState)
{
printf("Drawing %i copies of %s%s at %u x %u\n",
copyCount,
rivName.c_str(),
paused ? " (paused)" : "",
TestingWindow::Get()->width(),
TestingWindow::Get()->height());
lastReportedCopyCount = copyCount;
lastReportedPauseState = paused;
}
auto renderer = TestingWindow::Get()->beginFrame({
.clearColor = 0xff303030,
.doClear = true,
.wireframe = wireframe,
});
if (hotloadShaders)
{
hotloadShaders = false;
#ifndef RIVE_NO_STD_SYSTEM
std::system("sh rebuild_shaders.sh /tmp/rive");
TestingWindow::Get()->hotloadShaders();
#endif
}
renderer->save();
uint32_t width = TestingWindow::Get()->width();
uint32_t height = TestingWindow::Get()->height();
for (int i = rotations90; (i & 3) != 0; --i)
{
renderer->transform(rive::Mat2D(0, 1, -1, 0, width, 0));
std::swap(height, width);
}
if (zoomLevel != 0)
{
float scale = powf(1.25f, zoomLevel);
renderer->translate(width / 2.f, height / 2.f);
renderer->scale(scale, scale);
renderer->translate(width / -2.f, height / -2.f);
}
// Draw the .riv.
renderer->save();
renderer->align(rive::Fit::contain,
rive::Alignment::center,
rive::AABB(0, 0, width, height),
artboard->bounds());
float spacingPx = spacing * 5 + 150;
renderer->translate(-spacingPx * copiesLeft, -spacingPx * copiesAbove);
for (int y = -copiesAbove; y <= copiesBelow; ++y)
{
renderer->save();
for (int x = -copiesLeft; x <= copiesRight; ++x)
{
artboard->draw(renderer.get());
renderer->translate(spacingPx, 0);
}
renderer->restore();
renderer->translate(0, spacingPx);
}
renderer->restore();
if (fpsText != nullptr)
{
// Draw FPS.
renderer->save();
renderer->translate(0, 20);
fpsText->render(renderer.get(), blackStroke);
fpsText->render(renderer.get(), whiteFill);
renderer->restore();
}
renderer->restore();
TestingWindow::Get()->endFrame();
// Count FPS.
++fpsFrames;
const double elapsedFPSUpdate =
std::chrono::duration_cast<std::chrono::nanoseconds>(
timeNow - timeLastFPSUpdate)
.count() /
1e9; // convert to s
if (elapsedFPSUpdate >= 2.0)
{
double fps = fpsFrames / elapsedFPSUpdate;
printf("[%.3f FPS]\n", fps);
char fpsRawText[32];
snprintf(fpsRawText, sizeof(fpsRawText), " %.1f FPS ", fps);
fpsText = std::make_unique<rive::RawText>(
TestingWindow::Get()->factory());
fpsText->maxWidth(width);
#ifdef RIVE_ANDROID
fpsText->align(rive::TextAlign::center);
#ifdef __EMSCRIPTEN__
emscripten_set_main_loop([]() { player.doFrame(); }, 0, true);
#else
fpsText->align(rive::TextAlign::right);
#endif
fpsText->sizing(rive::TextSizing::fixed);
fpsText->append(fpsRawText, nullptr, roboto, 50.f);
fpsFrames = 0;
timeLastFPSUpdate = timeNow;
}
const rive::Mat2D alignmentMat =
computeAlignment(rive::Fit::contain,
rive::Alignment::center,
rive::AABB(0, 0, width, height),
artboard->bounds());
// Consume all input events until none are left in the queue
TestingWindow::InputEventData inputEventData;
while (TestingWindow::Get()->consumeInputEvent(inputEventData))
{
const rive::Vec2D mousePosAligned =
alignmentMat.invertOrIdentity() *
rive::Vec2D(inputEventData.metadata.posX,
inputEventData.metadata.posY);
switch (inputEventData.eventType)
{
case TestingWindow::InputEvent::KeyPress:
key_pressed(inputEventData.metadata.key);
break;
case TestingWindow::InputEvent::MouseMove:
scene->pointerMove(mousePosAligned);
break;
case TestingWindow::InputEvent::MouseDown:
scene->pointerDown(mousePosAligned);
break;
case TestingWindow::InputEvent::MouseUp:
scene->pointerUp(mousePosAligned);
break;
}
}
char key;
while (TestHarness::Instance().peekChar(key))
{
key_pressed(key);
}
#ifdef RIVE_ANDROID
if (!rive_android_app_poll_once())
{
break;
}
#endif
for (;;)
{
player.doFrame();
}
#endif
printf("\nShutting down\n");
TestingWindow::Destroy(); // Exercise our PLS teardown process now that
// we're done.
TestHarness::Instance().shutdown();
return 0;
}

32
tests/player/player.html Normal file
View File

@@ -0,0 +1,32 @@
<style>
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
background-color: black;
}
</style>
<canvas id="canvas"></canvas>
<script>
window.onhashchange = function() { location.reload(); }
function printMessageOnServer(text) {
const lengthBytes = lengthBytesUTF8(text) + 1;
const stringOnWasmHeap = Module['_malloc'](lengthBytes);
stringToUTF8(text, stringOnWasmHeap, lengthBytes);
Module['_rive_print_message_on_server'](stringOnWasmHeap);
Module['_free'](stringOnWasmHeap);
}
var Module = {
'canvas': document.getElementById("canvas"),
'print': function(text) {
printMessageOnServer(text + '\n');
console.log(text);
},
'printErr': function(text) {
printMessageOnServer(text + '\n');
console.error(text);
},
};
</script>
<script src="player.js"></script>

View File

@@ -14,6 +14,10 @@ do
do
defines({ 'RIVE_UNREAL' })
end
filter('system:emscripten')
do
files({ 'gm/gms.html' })
end
end
rive_tools_project('goldens', 'RiveTool')
@@ -24,11 +28,19 @@ do
do
defines({ 'RIVE_UNREAL' })
end
filter('system:emscripten')
do
files({ 'goldens/goldens.html' })
end
end
rive_tools_project('player', 'RiveTool')
do
files({ 'player/player.cpp' })
filter('system:emscripten')
do
files({ 'player/player.html' })
end
end
rive_tools_project('command_buffer_example', 'RiveTool')

View File

@@ -77,7 +77,6 @@ function rive_tools_project(name, project_kind)
'include',
RIVE_PLS_DIR .. '/glad',
RIVE_PLS_DIR .. '/glad/include',
RIVE_RUNTIME_DIR .. '/skia/dependencies/glfw/include',
yoga,
libpng,
zlib,
@@ -92,6 +91,13 @@ function rive_tools_project(name, project_kind)
dofile(RIVE_PLS_DIR .. '/rive_vk_bootstrap/bootstrap_project.lua')
end
filter({ 'system:windows or macosx or linux', 'options:not for_unreal' })
do
externalincludedirs({
RIVE_RUNTIME_DIR .. '/skia/dependencies/glfw/include',
})
end
filter('options:with-skia')
do
includedirs({
@@ -181,113 +187,115 @@ function rive_tools_project(name, project_kind)
'rive_sheenbidi',
'miniaudio',
})
end
filter({ 'kind:ConsoleApp or SharedLib or WindowedApp', 'options:not no_rive_jpeg' })
do
links({
'libjpeg',
})
end
filter({ 'kind:ConsoleApp or SharedLib or WindowedApp', 'options:not no_rive_jpeg' })
do
links({
'libjpeg',
})
end
if ndk then
relative_ndk = ndk
if string.sub(ndk, 1, 1) == '/' then
-- An absolute file path wasn't working with premake.
local current_path = string.gmatch(path.getabsolute('.'), '([^\\/]+)')
for dir in current_path do
relative_ndk = '../' .. relative_ndk
end
filter({})
if ndk then
relative_ndk = ndk
if string.sub(ndk, 1, 1) == '/' then
-- An absolute file path wasn't working with premake.
local current_path = string.gmatch(path.getabsolute('.'), '([^\\/]+)')
for dir in current_path do
relative_ndk = '../' .. relative_ndk
end
files({
relative_ndk .. '/sources/android/native_app_glue/android_native_app_glue.c',
})
end
filter({ 'kind:ConsoleApp or SharedLib or WindowedApp', 'system:windows' })
do
libdirs({
RIVE_RUNTIME_DIR .. '/skia/dependencies/glfw_build/src/Release',
})
links({
'glfw3',
'opengl32',
'd3d11',
'd3d12',
'dxguid',
'dxgi',
'Dbghelp',
'd3dcompiler',
'ws2_32',
})
end
filter({ 'kind:ConsoleApp or SharedLib or WindowedApp', 'system:macosx' })
do
libdirs({ RIVE_RUNTIME_DIR .. '/skia/dependencies/glfw_build/src' })
links({
'glfw3',
'Metal.framework',
'QuartzCore.framework',
'Cocoa.framework',
'CoreGraphics.framework',
'CoreFoundation.framework',
'CoreMedia.framework',
'CoreServices.framework',
'IOKit.framework',
'Security.framework',
'OpenGL.framework',
'bz2',
'iconv',
'lzma',
'z', -- lib av format
})
end
filter({ 'kind:ConsoleApp or SharedLib or WindowedApp', 'system:linux' })
do
libdirs({ RIVE_RUNTIME_DIR .. '/skia/dependencies/glfw_build/src' })
links({ 'glfw3', 'm', 'z', 'dl', 'pthread', 'GL' })
end
filter({ 'kind:ConsoleApp or SharedLib or WindowedApp', 'system:android' })
do
links({ 'EGL', 'GLESv3', 'log' })
end
filter({ 'kind:ConsoleApp or SharedLib or WindowedApp', 'options:with-dawn' })
do
libdirs({
RIVE_PLS_DIR .. '/dependencies/dawn/out/release/obj/src/dawn',
RIVE_PLS_DIR .. '/dependencies/dawn/out/release/obj/src/dawn/native',
RIVE_PLS_DIR .. '/dependencies/dawn/out/release/obj/src/dawn/platform',
RIVE_PLS_DIR .. '/dependencies/dawn/out/release/obj/src/dawn/platform',
})
links({
'winmm',
'webgpu_dawn',
'dawn_native_static',
'dawn_proc_static',
'dawn_platform_static',
})
end
filter({
'kind:ConsoleApp or SharedLib or WindowedApp',
'options:with-dawn',
'system:windows',
files({
relative_ndk .. '/sources/android/native_app_glue/android_native_app_glue.c',
})
do
links({ 'dxguid' })
end
end
filter({
'kind:ConsoleApp or SharedLib or WindowedApp',
'options:with-dawn',
'system:macosx',
filter({ 'kind:ConsoleApp or SharedLib or WindowedApp', 'system:windows' })
do
libdirs({
RIVE_RUNTIME_DIR .. '/skia/dependencies/glfw_build/src/Release',
})
do
links({ 'IOSurface.framework' })
end
links({
'glfw3',
'opengl32',
'd3d11',
'd3d12',
'dxguid',
'dxgi',
'Dbghelp',
'd3dcompiler',
'ws2_32',
})
end
filter({ 'kind:ConsoleApp or SharedLib or WindowedApp', 'system:macosx' })
do
libdirs({ RIVE_RUNTIME_DIR .. '/skia/dependencies/glfw_build/src' })
links({
'glfw3',
'Metal.framework',
'QuartzCore.framework',
'Cocoa.framework',
'CoreGraphics.framework',
'CoreFoundation.framework',
'CoreMedia.framework',
'CoreServices.framework',
'IOKit.framework',
'Security.framework',
'OpenGL.framework',
'bz2',
'iconv',
'lzma',
'z', -- lib av format
})
end
filter({ 'kind:ConsoleApp or SharedLib or WindowedApp', 'system:linux' })
do
libdirs({ RIVE_RUNTIME_DIR .. '/skia/dependencies/glfw_build/src' })
links({ 'glfw3', 'm', 'z', 'dl', 'pthread', 'GL' })
end
filter({ 'kind:ConsoleApp or SharedLib or WindowedApp', 'system:android' })
do
links({ 'EGL', 'GLESv3', 'log' })
end
filter({ 'kind:ConsoleApp or SharedLib or WindowedApp', 'options:with-dawn' })
do
libdirs({
RIVE_PLS_DIR .. '/dependencies/dawn/out/release/obj/src/dawn',
RIVE_PLS_DIR .. '/dependencies/dawn/out/release/obj/src/dawn/native',
RIVE_PLS_DIR .. '/dependencies/dawn/out/release/obj/src/dawn/platform',
RIVE_PLS_DIR .. '/dependencies/dawn/out/release/obj/src/dawn/platform',
})
links({
'winmm',
'webgpu_dawn',
'dawn_native_static',
'dawn_proc_static',
'dawn_platform_static',
})
end
filter({
'kind:ConsoleApp or SharedLib or WindowedApp',
'options:with-dawn',
'system:windows',
})
do
links({ 'dxguid' })
end
filter({
'kind:ConsoleApp or SharedLib or WindowedApp',
'options:with-dawn',
'system:macosx',
})
do
links({ 'IOSurface.framework' })
end
filter({ 'kind:ConsoleApp or SharedLib or WindowedApp', 'options:with-skia' })
@@ -295,7 +303,44 @@ function rive_tools_project(name, project_kind)
links({ 'skia', 'rive_skia_renderer' })
end
filter('system:emscripten')
do
targetextension('.js')
linkoptions({
'-sEXPORTED_FUNCTIONS=_main,_rive_print_message_on_server,_malloc,_free',
'-sEXPORTED_RUNTIME_METHODS=ccall,cwrap',
'-sENVIRONMENT=web',
'-sUSE_GLFW=3',
'-sMIN_WEBGL_VERSION=2',
'-sMAX_WEBGL_VERSION=2',
'-sASYNCIFY',
'-sASYNCIFY_IMPORTS="[async_sleep, wasi_snapshot_preview1.fd_write]"',
'-sASYNCIFY_STACK_SIZE=16384',
'-sGL_TESTING',
'-lwebsocket.js',
})
end
filter({ 'system:emscripten', 'options:with-webgpu', 'options:not with_wagyu' })
do
linkoptions({
'-sUSE_WEBGPU',
})
end
filter('files:**.html')
do
buildmessage('Copying %{file.relpath} to %{cfg.targetdir}')
buildcommands({ 'cp %{file.relpath} %{cfg.targetdir}/%{file.name}' })
buildoutputs({ '%{cfg.targetdir}/%{file.name}' })
end
filter({})
if RIVE_WAGYU_PORT then
buildoptions({ RIVE_WAGYU_PORT })
linkoptions({ RIVE_WAGYU_PORT })
end
end
rive_tools_project('tools_common', 'StaticLib')
@@ -340,5 +385,10 @@ do
})
end
filter('system:emscripten')
do
files({ 'common/rive_wasm_app.cpp' })
end
filter({})
end