Browse Source

Embedding assets into the application bundle on macOS (#1453)

* All asset reads go through the AssetStream class
* Implemented alert for macOS

Fixes #1452
Jorrit Rouwe 9 months ago
parent
commit
e030a579e4

+ 1 - 1
JoltViewer/JoltViewer.cmake

@@ -13,7 +13,7 @@ source_group(TREE ${JOLT_VIEWER_ROOT} FILES ${JOLT_VIEWER_SRC_FILES})
 
 # Create JoltViewer executable
 if ("${CMAKE_SYSTEM_NAME}" MATCHES "Darwin")
-	add_executable(JoltViewer MACOSX_BUNDLE ${JOLT_VIEWER_SRC_FILES})
+	add_executable(JoltViewer MACOSX_BUNDLE ${JOLT_VIEWER_SRC_FILES} ${TEST_FRAMEWORK_ASSETS})
 	set_property(TARGET JoltViewer PROPERTY MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/iOS/JoltViewerInfo.plist")
 	set_property(TARGET JoltViewer PROPERTY XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "com.joltphysics.joltviewer")
 else()

+ 30 - 1
Samples/Samples.cmake

@@ -308,12 +308,41 @@ if (ENABLE_OBJECT_STREAM)
 	)
 endif()
 
+# Assets used by the samples
+set(SAMPLES_ASSETS
+	${PHYSICS_REPO_ROOT}/Assets/convex_hulls.bin
+	${PHYSICS_REPO_ROOT}/Assets/heightfield1.bin
+	${PHYSICS_REPO_ROOT}/Assets/Human/dead_pose1.tof
+	${PHYSICS_REPO_ROOT}/Assets/Human/dead_pose2.tof
+	${PHYSICS_REPO_ROOT}/Assets/Human/dead_pose3.tof
+	${PHYSICS_REPO_ROOT}/Assets/Human/dead_pose4.tof
+	${PHYSICS_REPO_ROOT}/Assets/Human/jog_hd.tof
+	${PHYSICS_REPO_ROOT}/Assets/Human/neutral.tof
+	${PHYSICS_REPO_ROOT}/Assets/Human/neutral_hd.tof
+	${PHYSICS_REPO_ROOT}/Assets/Human/skeleton_hd.tof
+	${PHYSICS_REPO_ROOT}/Assets/Human/sprint.tof
+	${PHYSICS_REPO_ROOT}/Assets/Human/walk.tof
+	${PHYSICS_REPO_ROOT}/Assets/Human.tof
+	${PHYSICS_REPO_ROOT}/Assets/Racetracks/Zandvoort.csv
+	${PHYSICS_REPO_ROOT}/Assets/terrain1.bof
+	${PHYSICS_REPO_ROOT}/Assets/terrain2.bof
+)
+
 # Group source files
 source_group(TREE ${SAMPLES_ROOT} FILES ${SAMPLES_SRC_FILES})
 
 # Create Samples executable
 if ("${CMAKE_SYSTEM_NAME}" MATCHES "Darwin")
-	add_executable(Samples MACOSX_BUNDLE ${SAMPLES_SRC_FILES})
+	# macOS configuration
+	add_executable(Samples MACOSX_BUNDLE ${SAMPLES_SRC_FILES} ${TEST_FRAMEWORK_ASSETS} ${SAMPLES_ASSETS})
+
+	# Make sure that all samples assets move to the Resources folder in the package
+	foreach(ASSET_FILE ${SAMPLES_ASSETS})
+		string(REPLACE ${PHYSICS_REPO_ROOT}/Assets "Resources" ASSET_DST ${ASSET_FILE})
+		get_filename_component(ASSET_DST ${ASSET_DST} DIRECTORY)
+		set_source_files_properties(${ASSET_FILE} PROPERTIES MACOSX_PACKAGE_LOCATION ${ASSET_DST})
+	endforeach()
+
 	set_property(TARGET Samples PROPERTY MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/iOS/SamplesInfo.plist")
 	set_property(TARGET Samples PROPERTY XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "com.joltphysics.samples")
 else()

+ 3 - 1
Samples/Tests/Character/CharacterBaseTest.cpp

@@ -18,6 +18,7 @@
 #include <Application/DebugUI.h>
 #include <Layers.h>
 #include <Utils/Log.h>
+#include <Utils/AssetStream.h>
 #include <Renderer/DebugRendererImp.h>
 
 JPH_IMPLEMENT_RTTI_ABSTRACT(CharacterBaseTest)
@@ -563,7 +564,8 @@ void CharacterBaseTest::Initialize()
 	{
 		// Load scene
 		Ref<PhysicsScene> scene;
-		if (!ObjectStreamIn::sReadObject((String("Assets/") + sSceneName + ".bof").c_str(), scene))
+		AssetStream stream(String(sSceneName) + ".bof", std::ios::in | std::ios::binary);
+		if (!ObjectStreamIn::sReadObject(stream.Get(), scene))
 			FatalError("Failed to load scene");
 		scene->FixInvalidScales();
 		for (BodyCreationSettings &settings : scene->GetBodies())

+ 18 - 19
Samples/Tests/ConvexCollision/ConvexHullShrinkTest.cpp

@@ -10,6 +10,7 @@
 #include <Jolt/Geometry/ConvexSupport.h>
 #include <Jolt/Physics/Collision/Shape/ConvexHullShape.h>
 #include <Renderer/DebugRendererImp.h>
+#include <Utils/AssetStream.h>
 
 JPH_SUPPRESS_WARNINGS_STD_BEGIN
 #include <fstream>
@@ -91,29 +92,27 @@ void ConvexHullShrinkTest::Initialize()
 
 	// Open the external file with hulls
 	// A stream containing predefined convex hulls
-	ifstream points_stream("Assets/convex_hulls.bin", std::ios::binary);
-	if (points_stream.is_open())
+	AssetStream points_asset_stream("convex_hulls.bin", std::ios::in | std::ios::binary);
+	std::istream &points_stream = points_asset_stream.Get();
+	for (;;)
 	{
-		for (;;)
-		{
-			// Read the length of the next point cloud
-			uint32 len = 0;
-			points_stream.read((char *)&len, sizeof(len));
-			if (points_stream.eof())
-				break;
+		// Read the length of the next point cloud
+		uint32 len = 0;
+		points_stream.read((char *)&len, sizeof(len));
+		if (points_stream.eof())
+			break;
 
-			// Read the points
-			if (len > 0)
+		// Read the points
+		if (len > 0)
+		{
+			Points p;
+			for (uint32 i = 0; i < len; ++i)
 			{
-				Points p;
-				for (uint32 i = 0; i < len; ++i)
-				{
-					Float3 v;
-					points_stream.read((char *)&v, sizeof(v));
-					p.push_back(Vec3(v));
-				}
-				mPoints.push_back(std::move(p));
+				Float3 v;
+				points_stream.read((char *)&v, sizeof(v));
+				p.push_back(Vec3(v));
 			}
+			mPoints.push_back(std::move(p));
 		}
 	}
 }

+ 18 - 19
Samples/Tests/ConvexCollision/ConvexHullTest.cpp

@@ -7,6 +7,7 @@
 #include <Tests/ConvexCollision/ConvexHullTest.h>
 #include <Jolt/Geometry/ConvexHullBuilder.h>
 #include <Utils/Log.h>
+#include <Utils/AssetStream.h>
 #include <Utils/DebugRendererSP.h>
 
 JPH_SUPPRESS_WARNINGS_STD_BEGIN
@@ -457,29 +458,27 @@ void ConvexHullTest::Initialize()
 
 	// Open the external file with hulls
 	// A stream containing predefined convex hulls
-	ifstream points_stream("Assets/convex_hulls.bin", std::ios::binary);
-	if (points_stream.is_open())
+	AssetStream points_asset_stream("convex_hulls.bin", std::ios::in | std::ios::binary);
+	std::istream &points_stream = points_asset_stream.Get();
+	for (;;)
 	{
-		for (;;)
+		// Read the length of the next point cloud
+		uint32 len = 0;
+		points_stream.read((char *)&len, sizeof(len));
+		if (points_stream.eof())
+			break;
+
+		// Read the points
+		if (len > 0)
 		{
-			// Read the length of the next point cloud
-			uint32 len = 0;
-			points_stream.read((char *)&len, sizeof(len));
-			if (points_stream.eof())
-				break;
-
-			// Read the points
-			if (len > 0)
+			Points p;
+			for (uint32 i = 0; i < len; ++i)
 			{
-				Points p;
-				for (uint32 i = 0; i < len; ++i)
-				{
-					Float3 v;
-					points_stream.read((char *)&v, sizeof(v));
-					p.push_back(Vec3(v));
-				}
-				mPoints.push_back(std::move(p));
+				Float3 v;
+				points_stream.read((char *)&v, sizeof(v));
+				p.push_back(Vec3(v));
 			}
+			mPoints.push_back(std::move(p));
 		}
 	}
 }

+ 3 - 1
Samples/Tests/General/HighSpeedTest.cpp

@@ -18,6 +18,7 @@
 #include <Jolt/ObjectStream/ObjectStreamIn.h>
 #include <Application/DebugUI.h>
 #include <Utils/Log.h>
+#include <Utils/AssetStream.h>
 #include <Layers.h>
 
 JPH_IMPLEMENT_RTTI_VIRTUAL(HighSpeedTest)
@@ -330,7 +331,8 @@ void HighSpeedTest::CreateConvexOnTerrain1()
 #ifdef JPH_OBJECT_STREAM
 	// Load scene
 	Ref<PhysicsScene> scene;
-	if (!ObjectStreamIn::sReadObject("Assets/terrain1.bof", scene))
+	AssetStream stream("terrain1.bof", std::ios::in | std::ios::binary);
+	if (!ObjectStreamIn::sReadObject(stream.Get(), scene))
 		FatalError("Failed to load scene");
 	for (BodyCreationSettings &body : scene->GetBodies())
 		body.mObjectLayer = Layers::NON_MOVING;

+ 4 - 2
Samples/Tests/General/MultithreadedTest.cpp

@@ -17,6 +17,7 @@
 #include <Layers.h>
 #include <Utils/RagdollLoader.h>
 #include <Utils/Log.h>
+#include <Utils/AssetStream.h>
 #include <Renderer/DebugRendererImp.h>
 
 JPH_IMPLEMENT_RTTI_VIRTUAL(MultithreadedTest)
@@ -137,7 +138,7 @@ void MultithreadedTest::RagdollSpawner()
 
 #ifdef JPH_OBJECT_STREAM
 	// Load ragdoll
-	Ref<RagdollSettings> ragdoll_settings = RagdollLoader::sLoad("Assets/Human.tof", EMotionType::Dynamic);
+	Ref<RagdollSettings> ragdoll_settings = RagdollLoader::sLoad("Human.tof", EMotionType::Dynamic);
 	if (ragdoll_settings == nullptr)
 		FatalError("Could not load ragdoll");
 #else
@@ -151,7 +152,8 @@ void MultithreadedTest::RagdollSpawner()
 	{
 #ifdef JPH_OBJECT_STREAM
 		Ref<SkeletalAnimation> animation;
-		if (!ObjectStreamIn::sReadObject("Assets/Human/dead_pose1.tof", animation))
+		AssetStream stream("Human/dead_pose1.tof", std::ios::in);
+		if (!ObjectStreamIn::sReadObject(stream.Get(), animation))
 			FatalError("Could not open animation");
 		animation->Sample(0.0f, ragdoll_pose);
 #else

+ 4 - 2
Samples/Tests/General/SensorTest.cpp

@@ -11,6 +11,7 @@
 #include <Jolt/ObjectStream/ObjectStreamIn.h>
 #include <Utils/RagdollLoader.h>
 #include <Utils/Log.h>
+#include <Utils/AssetStream.h>
 #include <Layers.h>
 #include <Renderer/DebugRendererImp.h>
 
@@ -71,7 +72,7 @@ void SensorTest::Initialize()
 
 #ifdef JPH_OBJECT_STREAM
 	// Load ragdoll
-	Ref<RagdollSettings> ragdoll_settings = RagdollLoader::sLoad("Assets/Human.tof", EMotionType::Dynamic);
+	Ref<RagdollSettings> ragdoll_settings = RagdollLoader::sLoad("Human.tof", EMotionType::Dynamic);
 	if (ragdoll_settings == nullptr)
 		FatalError("Could not load ragdoll");
 #else
@@ -84,7 +85,8 @@ void SensorTest::Initialize()
 	{
 #ifdef JPH_OBJECT_STREAM
 		Ref<SkeletalAnimation> animation;
-		if (!ObjectStreamIn::sReadObject("Assets/Human/dead_pose1.tof", animation))
+		AssetStream stream("Human/dead_pose1.tof", std::ios::in);
+		if (!ObjectStreamIn::sReadObject(stream.Get(), animation))
 			FatalError("Could not open animation");
 		animation->Sample(0.0f, ragdoll_pose);
 #else

+ 4 - 2
Samples/Tests/Rig/BigWorldTest.cpp

@@ -16,6 +16,7 @@
 #include <Renderer/DebugRendererImp.h>
 #include <Layers.h>
 #include <Utils/Log.h>
+#include <Utils/AssetStream.h>
 #include <random>
 
 JPH_IMPLEMENT_RTTI_VIRTUAL(BigWorldTest)
@@ -39,11 +40,12 @@ void BigWorldTest::Initialize()
 	RefConst<Shape> shape = floor.GetShape();
 
 	// Load ragdoll
-	Ref<RagdollSettings> settings = RagdollLoader::sLoad("Assets/Human.tof", EMotionType::Dynamic);
+	Ref<RagdollSettings> settings = RagdollLoader::sLoad("Human.tof", EMotionType::Dynamic);
 
 	// Load animation
 	Ref<SkeletalAnimation> animation;
-	if (!ObjectStreamIn::sReadObject("Assets/Human/dead_pose1.tof", animation))
+	AssetStream stream("Human/dead_pose1.tof", std::ios::in);
+	if (!ObjectStreamIn::sReadObject(stream.Get(), animation))
 		FatalError("Could not open animation");
 	SkeletonPose pose;
 	pose.SetSkeleton(settings->GetSkeleton());

+ 4 - 3
Samples/Tests/Rig/KinematicRigTest.cpp

@@ -11,6 +11,7 @@
 #include <Application/DebugUI.h>
 #include <Layers.h>
 #include <Utils/Log.h>
+#include <Utils/AssetStream.h>
 
 JPH_IMPLEMENT_RTTI_VIRTUAL(KinematicRigTest)
 {
@@ -50,15 +51,15 @@ void KinematicRigTest::Initialize()
 		}
 
 	// Load ragdoll
-	mRagdollSettings = RagdollLoader::sLoad("Assets/Human.tof", EMotionType::Kinematic);
+	mRagdollSettings = RagdollLoader::sLoad("Human.tof", EMotionType::Kinematic);
 
 	// Create ragdoll
 	mRagdoll = mRagdollSettings->CreateRagdoll(0, 0, mPhysicsSystem);
 	mRagdoll->AddToPhysicsSystem(EActivation::Activate);
 
 	// Load animation
-	String filename = String("Assets/Human/") + sAnimationName + ".tof";
-	if (!ObjectStreamIn::sReadObject(filename.c_str(), mAnimation))
+	AssetStream stream(String("Human/") + sAnimationName + ".tof", std::ios::in);
+	if (!ObjectStreamIn::sReadObject(stream.Get(), mAnimation))
 		FatalError("Could not open animation");
 
 	// Initialize pose

+ 1 - 1
Samples/Tests/Rig/LoadRigTest.cpp

@@ -35,7 +35,7 @@ void LoadRigTest::Initialize()
 	CreateFloor();
 
 	// Load ragdoll
-	mRagdollSettings = RagdollLoader::sLoad("Assets/Human.tof", EMotionType::Dynamic, sConstraintType);
+	mRagdollSettings = RagdollLoader::sLoad("Human.tof", EMotionType::Dynamic, sConstraintType);
 
 	// Create ragdoll
 	mRagdoll = mRagdollSettings->CreateRagdoll(0, 0, mPhysicsSystem);

+ 1 - 1
Samples/Tests/Rig/LoadSaveBinaryRigTest.cpp

@@ -29,7 +29,7 @@ void LoadSaveBinaryRigTest::Initialize()
 
 	{
 		// Load ragdoll
-		Ref<RagdollSettings> settings = RagdollLoader::sLoad("Assets/Human.tof", EMotionType::Dynamic);
+		Ref<RagdollSettings> settings = RagdollLoader::sLoad("Human.tof", EMotionType::Dynamic);
 
 		// Add an additional constraint between the left and right arm to test loading/saving of additional constraints
 		const Skeleton *skeleton = settings->GetSkeleton();

+ 1 - 1
Samples/Tests/Rig/LoadSaveRigTest.cpp

@@ -30,7 +30,7 @@ void LoadSaveRigTest::Initialize()
 
 	{
 		// Load ragdoll
-		Ref<RagdollSettings> settings = RagdollLoader::sLoad("Assets/Human.tof", EMotionType::Dynamic);
+		Ref<RagdollSettings> settings = RagdollLoader::sLoad("Human.tof", EMotionType::Dynamic);
 
 		// Add an additional constraint between the left and right arm to test loading/saving of additional constraints
 		const Skeleton *skeleton = settings->GetSkeleton();

+ 4 - 3
Samples/Tests/Rig/PoweredRigTest.cpp

@@ -10,6 +10,7 @@
 #include <Application/DebugUI.h>
 #include <Utils/RagdollLoader.h>
 #include <Utils/Log.h>
+#include <Utils/AssetStream.h>
 
 JPH_IMPLEMENT_RTTI_VIRTUAL(PoweredRigTest)
 {
@@ -40,15 +41,15 @@ void PoweredRigTest::Initialize()
 	CreateFloor();
 
 	// Load ragdoll
-	mRagdollSettings = RagdollLoader::sLoad("Assets/Human.tof", EMotionType::Dynamic);
+	mRagdollSettings = RagdollLoader::sLoad("Human.tof", EMotionType::Dynamic);
 
 	// Create ragdoll
 	mRagdoll = mRagdollSettings->CreateRagdoll(0, 0, mPhysicsSystem);
 	mRagdoll->AddToPhysicsSystem(EActivation::Activate);
 
 	// Load animation
-	String filename = String("Assets/Human/") + sAnimationName + ".tof";
-	if (!ObjectStreamIn::sReadObject(filename.c_str(), mAnimation))
+	AssetStream stream(String("Human/") + sAnimationName + ".tof", std::ios::in);
+	if (!ObjectStreamIn::sReadObject(stream.Get(), mAnimation))
 		FatalError("Could not open animation");
 
 	// Initialize pose

+ 6 - 3
Samples/Tests/Rig/RigPileTest.cpp

@@ -17,6 +17,7 @@
 #include <Application/DebugUI.h>
 #include <Layers.h>
 #include <Utils/Log.h>
+#include <Utils/AssetStream.h>
 #include <random>
 
 JPH_IMPLEMENT_RTTI_VIRTUAL(RigPileTest)
@@ -64,7 +65,8 @@ void RigPileTest::Initialize()
 	{
 		// Load scene
 		Ref<PhysicsScene> scene;
-		if (!ObjectStreamIn::sReadObject((String("Assets/") + sSceneName + ".bof").c_str(), scene))
+		AssetStream stream(String(sSceneName) + ".bof", std::ios::in | std::ios::binary);
+		if (!ObjectStreamIn::sReadObject(stream.Get(), scene))
 			FatalError("Failed to load scene");
 		for (BodyCreationSettings &body : scene->GetBodies())
 			body.mObjectLayer = Layers::NON_MOVING;
@@ -73,14 +75,15 @@ void RigPileTest::Initialize()
 	}
 
 	// Load ragdoll
-	Ref<RagdollSettings> settings = RagdollLoader::sLoad("Assets/Human.tof", EMotionType::Dynamic);
+	Ref<RagdollSettings> settings = RagdollLoader::sLoad("Human.tof", EMotionType::Dynamic);
 
 	// Load animation
 	const int cAnimationCount = 4;
 	Ref<SkeletalAnimation> animation[cAnimationCount];
 	for (int i = 0; i < cAnimationCount; ++i)
 	{
-		if (!ObjectStreamIn::sReadObject(StringFormat("Assets/Human/dead_pose%d.tof", i + 1).c_str(), animation[i]))
+		AssetStream stream(StringFormat("Human/dead_pose%d.tof", i + 1), std::ios::in);
+		if (!ObjectStreamIn::sReadObject(stream.Get(), animation[i]))
 			FatalError("Could not open animation");
 	}
 

+ 23 - 10
Samples/Tests/Rig/SkeletonMapperTest.cpp

@@ -10,6 +10,7 @@
 #include <Renderer/DebugRendererImp.h>
 #include <Layers.h>
 #include <Utils/Log.h>
+#include <Utils/AssetStream.h>
 #include <Application/DebugUI.h>
 
 JPH_IMPLEMENT_RTTI_VIRTUAL(SkeletonMapperTest)
@@ -28,7 +29,7 @@ void SkeletonMapperTest::Initialize()
 	CreateFloor();
 
 	// Load ragdoll
-	mRagdollSettings = RagdollLoader::sLoad("Assets/Human.tof", EMotionType::Dynamic);
+	mRagdollSettings = RagdollLoader::sLoad("Human.tof", EMotionType::Dynamic);
 
 	// Create ragdoll
 	mRagdoll = mRagdollSettings->CreateRagdoll(0, 0, mPhysicsSystem);
@@ -36,23 +37,35 @@ void SkeletonMapperTest::Initialize()
 
 	// Load neutral animation for ragdoll
 	Ref<SkeletalAnimation> neutral_ragdoll;
-	if (!ObjectStreamIn::sReadObject("Assets/Human/neutral.tof", neutral_ragdoll))
-		FatalError("Could not open neutral animation");
+	{
+		AssetStream stream("Human/neutral.tof", std::ios::in);
+		if (!ObjectStreamIn::sReadObject(stream.Get(), neutral_ragdoll))
+			FatalError("Could not open neutral animation");
+	}
 
 	// Load animation skeleton
 	Ref<Skeleton> animation_skeleton;
-	if (!ObjectStreamIn::sReadObject("Assets/Human/skeleton_hd.tof", animation_skeleton))
-		FatalError("Could not open skeleton_hd");
-	animation_skeleton->CalculateParentJointIndices();
+	{
+		AssetStream stream("Human/skeleton_hd.tof", std::ios::in);
+		if (!ObjectStreamIn::sReadObject(stream.Get(), animation_skeleton))
+			FatalError("Could not open skeleton_hd");
+		animation_skeleton->CalculateParentJointIndices();
+	}
 
 	// Load neutral animation
 	Ref<SkeletalAnimation> neutral_animation;
-	if (!ObjectStreamIn::sReadObject("Assets/Human/neutral_hd.tof", neutral_animation))
-		FatalError("Could not open neutral_hd animation");
+	{
+		AssetStream stream("Human/neutral_hd.tof", std::ios::in);
+		if (!ObjectStreamIn::sReadObject(stream.Get(), neutral_animation))
+			FatalError("Could not open neutral_hd animation");
+	}
 
 	// Load test animation
-	if (!ObjectStreamIn::sReadObject("Assets/Human/jog_hd.tof", mAnimation))
-		FatalError("Could not open jog_hd animation");
+	{
+		AssetStream stream("Human/jog_hd.tof", std::ios::in);
+		if (!ObjectStreamIn::sReadObject(stream.Get(), mAnimation))
+			FatalError("Could not open jog_hd animation");
+	}
 
 	// Initialize pose
 	mAnimatedPose.SetSkeleton(animation_skeleton);

+ 1 - 1
Samples/Tests/Shapes/HeightFieldShapeTest.cpp

@@ -90,7 +90,7 @@ void HeightFieldShapeTest::Initialize()
 		const float cell_size = 0.5f;
 
 		// Get height samples
-		Array<uint8> data = ReadData("Assets/heightfield1.bin");
+		Array<uint8> data = ReadData("heightfield1.bin");
 		if (data.size() != sizeof(float) * n * n)
 			FatalError("Invalid file size");
 		mTerrainSize = n;

+ 6 - 6
Samples/Tests/Vehicle/VehicleTest.cpp

@@ -16,6 +16,7 @@
 #include <Layers.h>
 #include <Application/DebugUI.h>
 #include <Utils/Log.h>
+#include <Utils/AssetStream.h>
 #include <Renderer/DebugRendererImp.h>
 
 JPH_IMPLEMENT_RTTI_VIRTUAL(VehicleTest)
@@ -49,7 +50,7 @@ void VehicleTest::Initialize()
 		mBodyInterface->AddBody(floor.GetID(), EActivation::DontActivate);
 
 		// Load a race track to have something to assess speed and steering behavior
-		LoadRaceTrack("Assets/Racetracks/Zandvoort.csv");
+		LoadRaceTrack("Racetracks/Zandvoort.csv");
 	}
 	else if (strcmp(sSceneName, "Flat With Slope") == 0)
 	{
@@ -164,7 +165,8 @@ void VehicleTest::Initialize()
 	{
 		// Load scene
 		Ref<PhysicsScene> scene;
-		if (!ObjectStreamIn::sReadObject((String("Assets/") + sSceneName + ".bof").c_str(), scene))
+		AssetStream stream(String(sSceneName) + ".bof", std::ios::in | std::ios::binary);
+		if (!ObjectStreamIn::sReadObject(stream.Get(), scene))
 			FatalError("Failed to load scene");
 		for (BodyCreationSettings &body : scene->GetBodies())
 			body.mObjectLayer = Layers::NON_MOVING;
@@ -262,10 +264,8 @@ void VehicleTest::CreateRubble()
 void VehicleTest::LoadRaceTrack(const char *inFileName)
 {
 	// Open the track file
-	std::ifstream stream;
-	stream.open(inFileName, std::ifstream::in);
-	if (!stream.is_open())
-		return;
+	AssetStream asset_stream(inFileName, std::ios::in);
+	std::istream &stream = asset_stream.Get();
 
 	// Ignore header line
 	String line;

+ 3 - 1
Samples/Utils/RagdollLoader.cpp

@@ -17,6 +17,7 @@
 #include <Jolt/ObjectStream/ObjectStreamOut.h>
 #include <Layers.h>
 #include <Utils/Log.h>
+#include <Utils/AssetStream.h>
 
 #ifdef JPH_OBJECT_STREAM
 
@@ -24,7 +25,8 @@ RagdollSettings *RagdollLoader::sLoad(const char *inFileName, EMotionType inMoti
 {
 	// Read the ragdoll
 	RagdollSettings *ragdoll = nullptr;
-	if (!ObjectStreamIn::sReadObject(inFileName, ragdoll))
+	AssetStream stream(inFileName, std::ios::in);
+	if (!ObjectStreamIn::sReadObject(stream.Get(), ragdoll))
 		FatalError("Unable to read ragdoll");
 
 	for (RagdollSettings::Part &p : ragdoll->mParts)

+ 3 - 5
TestFramework/Application/DebugUI.cpp

@@ -15,6 +15,7 @@
 #include <UI/UITextButton.h>
 #include <Image/LoadTGA.h>
 #include <Utils/Log.h>
+#include <Utils/AssetStream.h>
 
 JPH_SUPPRESS_WARNINGS_STD_BEGIN
 #include <fstream>
@@ -25,11 +26,8 @@ DebugUI::DebugUI(UIManager *inUIManager, const Font *inFont) :
 	mFont(inFont)
 {
 	// Load UI texture with commonly used UI elements
-	ifstream texture_stream;
-	texture_stream.open("Assets/UI.tga", ifstream::binary);
-	if (texture_stream.fail())
-		FatalError("Failed to open UI.tga");
-	Ref<Surface> texture_surface = LoadTGA(texture_stream);
+	AssetStream texture_stream("UI.tga", std::ios::in | std::ios::binary);
+	Ref<Surface> texture_surface = LoadTGA(texture_stream.Get());
 	if (texture_surface == nullptr)
 		FatalError("Failed to load UI.tga");
 	mUITexture = mUI->GetRenderer()->CreateTexture(texture_surface);

+ 5 - 4
TestFramework/Renderer/DX12/RendererDX12.cpp

@@ -16,6 +16,7 @@
 #include <Jolt/Core/Profiler.h>
 #include <Utils/ReadData.h>
 #include <Utils/Log.h>
+#include <Utils/AssetStream.h>
 
 #include <d3dcompiler.h>
 #ifdef JPH_DEBUG
@@ -525,14 +526,14 @@ Ref<VertexShader> RendererDX12::CreateVertexShader(const char *inName)
 	};
 
 	// Read shader source file
-	String file_name = String("Assets/Shaders/DX/") + inName + ".hlsl";
+	String file_name = String("Shaders/DX/") + inName + ".hlsl";
 	Array<uint8> data = ReadData(file_name.c_str());
 
 	// Compile source
 	ComPtr<ID3DBlob> shader_blob, error_blob;
 	HRESULT hr = D3DCompile(&data[0],
 							(uint)data.size(),
-							file_name.c_str(),
+							(AssetStream::sGetAssetsBasePath() + file_name).c_str(),
 							defines,
 							D3D_COMPILE_STANDARD_FILE_INCLUDE,
 							"main",
@@ -565,14 +566,14 @@ Ref<PixelShader> RendererDX12::CreatePixelShader(const char *inName)
 	};
 
 	// Read shader source file
-	String file_name = String("Assets/Shaders/DX/") + inName + ".hlsl";
+	String file_name = String("Shaders/DX/") + inName + ".hlsl";
 	Array<uint8> data = ReadData(file_name.c_str());
 
 	// Compile source
 	ComPtr<ID3DBlob> shader_blob, error_blob;
 	HRESULT hr = D3DCompile(&data[0],
 							(uint)data.size(),
-							file_name.c_str(),
+							(AssetStream::sGetAssetsBasePath() + file_name).c_str(),
 							defines,
 							D3D_COMPILE_STANDARD_FILE_INCLUDE,
 							"main",

+ 1 - 1
TestFramework/Renderer/Font.cpp

@@ -41,7 +41,7 @@ bool Font::Create(const char *inFontName, int inCharHeight)
 	constexpr int cSpacingV = 2; // Number of pixels to put vertically between characters
 
 	// Read font data
-	Array<uint8> font_data = ReadData((String("Assets/Fonts/") + inFontName + ".ttf").c_str());
+	Array<uint8> font_data = ReadData((String("Fonts/") + inFontName + ".ttf").c_str());
 
 	// Construct a font info
 	stbtt_fontinfo font;

+ 4 - 3
TestFramework/Renderer/MTL/RendererMTL.mm

@@ -14,6 +14,7 @@
 #include <Renderer/MTL/FatalErrorIfFailedMTL.h>
 #include <Window/ApplicationWindowMacOS.h>
 #include <Utils/Log.h>
+#include <Utils/AssetStream.h>
 #include <Jolt/Core/Profiler.h>
 
 RendererMTL::~RendererMTL()
@@ -33,7 +34,7 @@ void RendererMTL::Initialize(ApplicationWindow *inWindow)
 
 	// Load the shader library containing all shaders for the test framework
 	NSError *error = nullptr;
-	NSURL *url = [NSURL URLWithString: @"Assets/Shaders/MTL/Shaders.metallib"];
+	NSURL *url = [NSURL URLWithString: [NSString stringWithCString: (AssetStream::sGetAssetsBasePath() + "Shaders/MTL/Shaders.metallib").c_str() encoding: NSUTF8StringEncoding]];
 	mShaderLibrary = [device newLibraryWithURL: url error: &error];
 	FatalErrorIfFailed(error);
 
@@ -144,7 +145,7 @@ Ref<Texture> RendererMTL::CreateTexture(const Surface *inSurface)
 
 Ref<VertexShader> RendererMTL::CreateVertexShader(const char *inName)
 {
-	id<MTLFunction> function = [mShaderLibrary newFunctionWithName: [[[NSString alloc] initWithUTF8String: inName] autorelease]];
+	id<MTLFunction> function = [mShaderLibrary newFunctionWithName: [NSString stringWithCString: inName encoding: NSUTF8StringEncoding]];
 	if (function == nil)
 		FatalError("Vertex shader %s not found", inName);
 	return new VertexShaderMTL(function);
@@ -152,7 +153,7 @@ Ref<VertexShader> RendererMTL::CreateVertexShader(const char *inName)
 
 Ref<PixelShader> RendererMTL::CreatePixelShader(const char *inName)
 {
-	id<MTLFunction> function = [mShaderLibrary newFunctionWithName: [[[NSString alloc] initWithUTF8String: inName] autorelease]];
+	id<MTLFunction> function = [mShaderLibrary newFunctionWithName: [NSString stringWithCString: inName encoding: NSUTF8StringEncoding]];
 	if (function == nil)
 		FatalError("Pixel shader %s not found", inName);
 	return new PixelShaderMTL(function);

+ 2 - 2
TestFramework/Renderer/VK/RendererVK.cpp

@@ -967,7 +967,7 @@ Ref<Texture> RendererVK::CreateTexture(const Surface *inSurface)
 
 Ref<VertexShader> RendererVK::CreateVertexShader(const char *inName)
 {
-	Array<uint8> data = ReadData((String("Assets/Shaders/VK/") + inName + ".vert.spv").c_str());
+	Array<uint8> data = ReadData((String("Shaders/VK/") + inName + ".vert.spv").c_str());
 
 	VkShaderModuleCreateInfo create_info = {};
 	create_info.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
@@ -981,7 +981,7 @@ Ref<VertexShader> RendererVK::CreateVertexShader(const char *inName)
 
 Ref<PixelShader> RendererVK::CreatePixelShader(const char *inName)
 {
-	Array<uint8> data = ReadData((String("Assets/Shaders/VK/") + inName + ".frag.spv").c_str());
+	Array<uint8> data = ReadData((String("Shaders/VK/") + inName + ".frag.spv").c_str());
 
 	VkShaderModuleCreateInfo create_info = {};
 	create_info.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;

+ 25 - 1
TestFramework/TestFramework.cmake

@@ -75,7 +75,7 @@ if (NOT CROSS_COMPILE_ARM AND (Vulkan_FOUND OR WIN32 OR ("${CMAKE_SYSTEM_NAME}"
 		${TEST_FRAMEWORK_ROOT}/UI/UIVerticalStack.h
 		${TEST_FRAMEWORK_ROOT}/Utils/CustomMemoryHook.cpp
 		${TEST_FRAMEWORK_ROOT}/Utils/CustomMemoryHook.h
-		${TEST_FRAMEWORK_ROOT}/Utils/Log.cpp
+		${TEST_FRAMEWORK_ROOT}/Utils/AssetStream.h
 		${TEST_FRAMEWORK_ROOT}/Utils/Log.h
 		${TEST_FRAMEWORK_ROOT}/Utils/ReadData.cpp
 		${TEST_FRAMEWORK_ROOT}/Utils/ReadData.h
@@ -108,6 +108,8 @@ if (NOT CROSS_COMPILE_ARM AND (Vulkan_FOUND OR WIN32 OR ("${CMAKE_SYSTEM_NAME}"
 			${TEST_FRAMEWORK_ROOT}/Renderer/DX12/TextureDX12.cpp
 			${TEST_FRAMEWORK_ROOT}/Renderer/DX12/TextureDX12.h
 			${TEST_FRAMEWORK_ROOT}/Renderer/DX12/VertexShaderDX12.h
+			${TEST_FRAMEWORK_ROOT}/Utils/AssetStream.cpp
+			${TEST_FRAMEWORK_ROOT}/Utils/Log.cpp
 			${TEST_FRAMEWORK_ROOT}/Window/ApplicationWindowWin.cpp
 			${TEST_FRAMEWORK_ROOT}/Window/ApplicationWindowWin.h
 		)
@@ -147,6 +149,8 @@ if (NOT CROSS_COMPILE_ARM AND (Vulkan_FOUND OR WIN32 OR ("${CMAKE_SYSTEM_NAME}"
 			${TEST_FRAMEWORK_ROOT}/Input/Linux/KeyboardLinux.h
 			${TEST_FRAMEWORK_ROOT}/Input/Linux/MouseLinux.cpp
 			${TEST_FRAMEWORK_ROOT}/Input/Linux/MouseLinux.h
+			${TEST_FRAMEWORK_ROOT}/Utils/AssetStream.cpp
+			${TEST_FRAMEWORK_ROOT}/Utils/Log.cpp
 			${TEST_FRAMEWORK_ROOT}/Window/ApplicationWindowLinux.cpp
 			${TEST_FRAMEWORK_ROOT}/Window/ApplicationWindowLinux.h
 		)
@@ -174,6 +178,8 @@ if (NOT CROSS_COMPILE_ARM AND (Vulkan_FOUND OR WIN32 OR ("${CMAKE_SYSTEM_NAME}"
 			${TEST_FRAMEWORK_ROOT}/Input/MacOS/KeyboardMacOS.h
 			${TEST_FRAMEWORK_ROOT}/Input/MacOS/MouseMacOS.mm
 			${TEST_FRAMEWORK_ROOT}/Input/MacOS/MouseMacOS.h
+			${TEST_FRAMEWORK_ROOT}/Utils/AssetStream.mm
+			${TEST_FRAMEWORK_ROOT}/Utils/Log.mm
 			${TEST_FRAMEWORK_ROOT}/Window/ApplicationWindowMacOS.mm
 			${TEST_FRAMEWORK_ROOT}/Window/ApplicationWindowMacOS.h
 		)
@@ -262,6 +268,17 @@ if (NOT CROSS_COMPILE_ARM AND (Vulkan_FOUND OR WIN32 OR ("${CMAKE_SYSTEM_NAME}"
 		endforeach()
 	endif()
 
+	# Assets used by the test framework
+	set(TEST_FRAMEWORK_ASSETS
+		${PHYSICS_REPO_ROOT}/Assets/Fonts/Roboto-Regular.ttf
+		${PHYSICS_REPO_ROOT}/Assets/UI.tga
+		${TEST_FRAMEWORK_SRC_FILES_SHADERS}
+		${TEST_FRAMEWORK_HLSL_VERTEX_SHADERS}
+		${TEST_FRAMEWORK_HLSL_PIXEL_SHADERS}
+		${TEST_FRAMEWORK_SPV_SHADERS}
+		${TEST_FRAMEWORK_METAL_LIB}
+	)
+
 	# Group source files
 	source_group(TREE ${TEST_FRAMEWORK_ROOT} FILES ${TEST_FRAMEWORK_SRC_FILES})
 
@@ -296,6 +313,13 @@ if (NOT CROSS_COMPILE_ARM AND (Vulkan_FOUND OR WIN32 OR ("${CMAKE_SYSTEM_NAME}"
 		# macOS configuration
 		target_link_libraries(TestFramework LINK_PUBLIC Jolt "-framework Cocoa -framework Metal -framework MetalKit -framework GameController")
 
+		# Make sure that all test framework assets move to the Resources folder in the package
+		foreach(ASSET_FILE ${TEST_FRAMEWORK_ASSETS})
+			string(REPLACE ${PHYSICS_REPO_ROOT}/Assets "Resources" ASSET_DST ${ASSET_FILE})
+			get_filename_component(ASSET_DST ${ASSET_DST} DIRECTORY)
+			set_source_files_properties(${ASSET_FILE} PROPERTIES MACOSX_PACKAGE_LOCATION ${ASSET_DST})
+		endforeach()
+
 		# Ignore PCH files for .mm files
 		foreach(SRC_FILE ${TEST_FRAMEWORK_SRC_FILES})
 			if (SRC_FILE MATCHES "\.mm")

+ 19 - 0
TestFramework/Utils/AssetStream.cpp

@@ -0,0 +1,19 @@
+// Jolt Physics Library (https://github.com/jrouwe/JoltPhysics)
+// SPDX-FileCopyrightText: 2025 Jorrit Rouwe
+// SPDX-License-Identifier: MIT
+
+#include <TestFramework.h>
+#include <Utils/AssetStream.h>
+#include <Utils/Log.h>
+
+String AssetStream::sGetAssetsBasePath()
+{
+	return "Assets/";
+}
+
+AssetStream::AssetStream(const char *inFileName, std::ios_base::openmode inOpenMode) :
+	mStream((sGetAssetsBasePath() + inFileName).c_str(), inOpenMode)
+{
+	if (!mStream.is_open())
+		FatalError("Failed to open file %s", inFileName);
+}

+ 27 - 0
TestFramework/Utils/AssetStream.h

@@ -0,0 +1,27 @@
+// Jolt Physics Library (https://github.com/jrouwe/JoltPhysics)
+// SPDX-FileCopyrightText: 2025 Jorrit Rouwe
+// SPDX-License-Identifier: MIT
+
+#pragma once
+
+JPH_SUPPRESS_WARNINGS_STD_BEGIN
+#include <fstream>
+JPH_SUPPRESS_WARNINGS_STD_END
+
+/// An istream interface that reads data from a file in the Assets folder
+class AssetStream
+{
+public:
+	/// Constructor
+					AssetStream(const char *inFileName, std::ios_base::openmode inOpenMode);
+					AssetStream(const String &inFileName, std::ios_base::openmode inOpenMode) : AssetStream(inFileName.c_str(), inOpenMode) { }
+
+	/// Get the path to the assets folder
+	static String	sGetAssetsBasePath();
+
+	/// Get the stream
+	std::istream &	Get()							{ return mStream; }
+
+private:
+	std::ifstream	mStream;
+};

+ 24 - 0
TestFramework/Utils/AssetStream.mm

@@ -0,0 +1,24 @@
+// Jolt Physics Library (https://github.com/jrouwe/JoltPhysics)
+// SPDX-FileCopyrightText: 2025 Jorrit Rouwe
+// SPDX-License-Identifier: MIT
+
+#include <TestFramework.h>
+#include <Utils/AssetStream.h>
+#include <Utils/Log.h>
+
+#include <Cocoa/Cocoa.h>
+
+String AssetStream::sGetAssetsBasePath()
+{
+	NSBundle *bundle = [NSBundle mainBundle];
+	String path = [[[bundle resourceURL] path] cStringUsingEncoding: NSUTF8StringEncoding];
+	path += "/";
+	return path;
+}
+
+AssetStream::AssetStream(const char *inFileName, std::ios_base::openmode inOpenMode) :
+	mStream((sGetAssetsBasePath() + inFileName).c_str(), inOpenMode)
+{
+	if (!mStream.is_open())
+		FatalError("Failed to open file %s", inFileName);
+}

+ 57 - 0
TestFramework/Utils/Log.mm

@@ -0,0 +1,57 @@
+// Jolt Physics Library (https://github.com/jrouwe/JoltPhysics)
+// SPDX-FileCopyrightText: 2025 Jorrit Rouwe
+// SPDX-License-Identifier: MIT
+
+#include <TestFramework.h>
+#include <Utils/Log.h>
+#include <cstdarg>
+
+#include <Cocoa/Cocoa.h>
+
+// Trace to TTY
+void TraceImpl(const char *inFMT, ...)
+{
+	// Format the message
+	va_list list;
+	va_start(list, inFMT);
+	char buffer[1024];
+	vsnprintf(buffer, sizeof(buffer), inFMT, list);
+	va_end(list);
+
+	// Log to the console
+	printf("%s\n", buffer);
+}
+
+void Alert(const char *inFMT, ...)
+{
+	// Format the message
+	va_list list;
+	va_start(list, inFMT);
+	char buffer[1024];
+	vsnprintf(buffer, sizeof(buffer), inFMT, list);
+	va_end(list);
+
+	Trace("Alert: %s", buffer);
+
+	NSAlert *alert = [[[NSAlert alloc] init] autorelease];
+	alert.messageText = [NSString stringWithCString: buffer encoding: NSUTF8StringEncoding];
+	[alert runModal];
+}
+
+void FatalError [[noreturn]] (const char *inFMT, ...)
+{
+	// Format the message
+	va_list list;
+	va_start(list, inFMT);
+	char buffer[1024];
+	vsnprintf(buffer, sizeof(buffer), inFMT, list);
+	va_end(list);
+
+	Trace("Fatal Error: %s", buffer);
+
+	NSAlert *alert = [[[NSAlert alloc] init] autorelease];
+	alert.messageText = [NSString stringWithCString: buffer encoding: NSUTF8StringEncoding];
+	[alert runModal];
+	
+	exit(1);
+}

+ 3 - 3
TestFramework/Utils/ReadData.cpp

@@ -5,6 +5,7 @@
 #include <TestFramework.h>
 #include <Utils/ReadData.h>
 #include <Utils/Log.h>
+#include <Utils/AssetStream.h>
 
 JPH_SUPPRESS_WARNINGS_STD_BEGIN
 #include <fstream>
@@ -14,9 +15,8 @@ JPH_SUPPRESS_WARNINGS_STD_END
 Array<uint8> ReadData(const char *inFileName)
 {
 	Array<uint8> data;
-	ifstream input(inFileName, std::ios::binary);
-	if (!input)
-		FatalError("Unable to open file: %s", inFileName);
+	AssetStream asset_stream(inFileName, std::ios::in | std::ios::binary);
+	std::istream &input = asset_stream.Get();
 	input.seekg(0, ios_base::end);
 	ifstream::pos_type length = input.tellg();
 	input.seekg(0, ios_base::beg);

+ 2 - 2
TestFramework/Window/ApplicationWindowMacOS.h

@@ -32,8 +32,8 @@ public:
 	virtual void					MainLoop(RenderCallback inRenderCallback) override;
 	
 	/// Call the render callback
-	bool							RenderCallback()						{ return mRenderCallback(); }
-	
+	bool							RenderCallback()						{ return mRenderCallback && mRenderCallback(); }
+
 	/// Subscribe to mouse move callbacks that supply window coordinates
 	using MouseMovedCallback = function<void(int, int)>;
 	void							SetMouseMovedCallback(MouseMovedCallback inCallback) { mMouseMovedCallback = inCallback; }