From ba4dfb78561ba73bb0dab4c06f5365098f83cf9e Mon Sep 17 00:00:00 2001 From: kindem Date: Sun, 14 Jun 2026 18:03:23 +0800 Subject: [PATCH 01/11] build: skip publishing conan recipes already present on the remote --- ThirdParty/ConanRecipes/build_recipes.py | 98 ++++++++++++++++++++---- 1 file changed, 85 insertions(+), 13 deletions(-) diff --git a/ThirdParty/ConanRecipes/build_recipes.py b/ThirdParty/ConanRecipes/build_recipes.py index 46296bc8..b26a6d9d 100644 --- a/ThirdParty/ConanRecipes/build_recipes.py +++ b/ThirdParty/ConanRecipes/build_recipes.py @@ -10,11 +10,17 @@ every recipe, so a subsequent 'conan install --build=missing' resolves recipes from the local cache and builds whatever the remote has no binaries for. CI uses this to validate recipe changes together with the engine build. + +Before building each recipe the script exports it and runs 'conan graph info' to +see whether a matching binary already exists on the remote. If so the recipe is +skipped entirely -- no download, no rebuild, and no no-op re-upload. Pass +--no-skip-existing to force every supported recipe through 'conan create'. """ from __future__ import annotations import argparse +import json import platform import re import subprocess @@ -27,6 +33,11 @@ SUPPORTED_PLATFORMS = ("Windows-x86_64", "Macos-armv8") +# 'conan graph info' binary states that mean a matching binary already exists (in the local cache or on +# a remote) and so needs neither building nor downloading-to-rebuild. We skip 'conan create' for these +# so an unchanged recipe is never pulled from the remote just to be re-uploaded as a no-op. +ALREADY_AVAILABLE_BINARY_STATES = {"Cache", "Download", "Update"} + def current_platform() -> str: system = platform.system() @@ -116,6 +127,48 @@ def run(cmd: list[str], cwd: Path | None = None, redact: set[str] | None = None) return subprocess.run(cmd, cwd=str(cwd) if cwd else None).returncode +def run_capture(cmd: list[str], cwd: Path | None = None) -> tuple[int, str]: + print(f"\n$ {' '.join(cmd)}", flush=True) + proc = subprocess.run(cmd, cwd=str(cwd) if cwd else None, stdout=subprocess.PIPE, text=True) + return proc.returncode, proc.stdout + + +def find_binary_state(graph_json: str, reference: str) -> str | None: + try: + nodes = json.loads(graph_json).get("graph", {}).get("nodes", {}) + except (json.JSONDecodeError, AttributeError): + return None + for node in nodes.values(): + ref = node.get("ref") or "" + if ref.split("#", 1)[0] == reference: + return node.get("binary") + return None + + +def remote_already_has(args: argparse.Namespace, recipe: Recipe, create_extra: list[str]) -> bool: + # Export so the local recipe revision (a hash of the recipe's contents) lands in the cache; for an + # unchanged recipe it equals the revision already on the remote, letting 'conan graph info' resolve + # the matching binary. Any uncertainty -- export/query failure, unparseable output, an unexpected + # state -- falls through to a normal 'conan create'; the pre-check only ever short-circuits a sure + # hit, never a guess. + export = [args.conan, "export", f"{recipe.dir_name}/conanfile.py", "--version", recipe.version] + if run(export, cwd=args.recipes_root) != 0: + return False + + info = [args.conan, "graph", "info", f"--requires={recipe.reference}", "--format=json"] + if args.remote: + info += ["-r", args.remote] + info += create_extra + code, out = run_capture(info, cwd=args.recipes_root) + if code != 0: + return False + + state = find_binary_state(out, recipe.reference) + if state: + print(f" remote binary state: {state}", flush=True) + return state in ALREADY_AVAILABLE_BINARY_STATES + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Build and optionally upload all Conan recipes.", @@ -155,6 +208,12 @@ def parse_args() -> argparse.Namespace: action="store_true", help="only 'conan export' every version of every recipe; no build, no platform filter", ) + parser.add_argument( + "--skip-existing", + action=argparse.BooleanOptionalAction, + default=True, + help="before building, query the remote and skip any recipe whose binary is already published", + ) upload = parser.add_argument_group("upload") upload.add_argument( @@ -193,6 +252,7 @@ def export_all(args: argparse.Namespace, recipes: list[Recipe]): def build_all(args: argparse.Namespace, recipes: list[Recipe], host: str): built: list[Recipe] = [] skipped: list[tuple[Recipe, str]] = [] + present: list[Recipe] = [] create_extra: list[str] = [] for profile in args.profile: @@ -202,7 +262,7 @@ def build_all(args: argparse.Namespace, recipes: list[Recipe], host: str): for index, recipe in enumerate(recipes, start=1): header = f"[{index}/{len(recipes)}] {recipe.name}" if recipe.version is None: - fail(args, built, skipped, recipe, + fail(args, built, skipped, present, recipe, f"could not determine latest version from {recipe.conandata}") if not recipe.supports(host): reason = f"not built on {host} (platforms: {recipe.platforms or 'none'})" @@ -210,6 +270,11 @@ def build_all(args: argparse.Namespace, recipes: list[Recipe], host: str): skipped.append((recipe, reason)) continue + if args.skip_existing and remote_already_has(args, recipe, create_extra): + print(f"\n=== {header} -- {recipe.reference} already on remote, skipping ===", flush=True) + present.append(recipe) + continue + print(f"\n=== {header} -- building {recipe.reference} ===", flush=True) start = time.time() cmd = [ @@ -220,25 +285,23 @@ def build_all(args: argparse.Namespace, recipes: list[Recipe], host: str): code = run(cmd, cwd=args.recipes_root) elapsed = time.time() - start if code != 0: - fail(args, built, skipped, recipe, + fail(args, built, skipped, present, recipe, f"'conan create' exited with code {code} after {elapsed:.0f}s") print(f"--- {recipe.reference} built in {elapsed:.0f}s ---", flush=True) built.append(recipe) - return built, skipped + return built, skipped, present -def fail(args, built, skipped, recipe: Recipe, message: str): +def fail(args, built, skipped, present, recipe: Recipe, message: str): print(f"\n!!! BUILD FAILED: {recipe.name} -- {message}", file=sys.stderr, flush=True) - print_summary(built, skipped, failed=recipe) + print_summary(built, skipped, present, failed=recipe) if args.upload: print("\nUpload skipped: not all recipes built successfully.", flush=True) sys.exit(1) -def upload_all(args: argparse.Namespace, built: list[Recipe]): - print("\n=== uploading packages ===", flush=True) - +def configure_remote(args: argparse.Namespace): if args.remote_url: if run([args.conan, "remote", "add", "--force", args.remote, args.remote_url]) != 0: sys.exit("error: failed to register remote") @@ -252,6 +315,9 @@ def upload_all(args: argparse.Namespace, built: list[Recipe]): if run(login, redact=redact) != 0: sys.exit("error: failed to log in to remote") + +def upload_all(args: argparse.Namespace, built: list[Recipe]): + print("\n=== uploading packages ===", flush=True) for recipe in built: print(f"\n--- uploading {recipe.reference} ---", flush=True) if run([args.conan, "upload", recipe.reference, "-r", args.remote, "--confirm"]) != 0: @@ -259,18 +325,20 @@ def upload_all(args: argparse.Namespace, built: list[Recipe]): print(f"\nUploaded {len(built)} package(s) to '{args.remote}'.", flush=True) -def print_summary(built, skipped, failed: Recipe | None = None): +def print_summary(built, skipped, present, failed: Recipe | None = None): print("\n" + "=" * 60, flush=True) print("SUMMARY", flush=True) print("=" * 60, flush=True) for recipe in built: print(f" [OK] {recipe.reference}", flush=True) + for recipe in present: + print(f" [REMOTE] {recipe.reference} (already published)", flush=True) for recipe, reason in skipped: print(f" [SKIP] {recipe.name} ({reason})", flush=True) if failed is not None: print(f" [FAILED] {failed.reference}", flush=True) print( - f"\nbuilt: {len(built)} skipped: {len(skipped)}" + f"\nbuilt: {len(built)} on-remote: {len(present)} skipped: {len(skipped)}" + (" failed: 1" if failed is not None else ""), flush=True, ) @@ -300,12 +368,16 @@ def main(): recipes = order_by_dependencies(recipes) print("Build order: " + ", ".join(r.name for r in recipes), flush=True) - built, skipped = build_all(args, recipes, host) - print_summary(built, skipped) + # Log in before building so the per-recipe pre-check can query the remote for existing binaries. + if args.upload: + configure_remote(args) + + built, skipped, present = build_all(args, recipes, host) + print_summary(built, skipped, present) if args.upload: if not built: - print("\nNothing was built; skipping upload.", flush=True) + print("\nNothing to upload; every built recipe was already on the remote.", flush=True) else: upload_all(args, built) print("\nAll done.", flush=True) From e5f3699448ddff03123cd628ba2254e1c194eb1b Mon Sep 17 00:00:00 2001 From: kindem Date: Sun, 14 Jun 2026 18:37:42 +0800 Subject: [PATCH 02/11] fix: fix several latent bugs and complete test coverage Fix bugs surfaced while expanding Common/Math test coverage; most were latent because the affected template interfaces were never instantiated: - Quaternion::Dot used '*' instead of '+' between terms - Quaternion::operator*=(T) was wrongly const; operator*=(Quaternion) used 'this * rhs' instead of '*this * rhs' - Quaternion lacked CastTo, which Transform::CastTo depends on - Rect::Size declared the diagonal as Vec (a Vec expression) - Color::ToHexString streamed uint8_t as characters; padded hex now - ColorConsts black/red/green/blue used channel/alpha value 1 instead of 255 - Transform::TransformPosition(Vec4) passed a const lvalue to FromColVecs, which only accepts an rvalue Vec - ViewTransform::LookAt returned Transform through an explicit ctor - Vector: renamed misspelled const negaUintZ to negaUnitZ Reorganize MathTest.cpp by class and add cases covering the previously untested interfaces across every Math type. (cherry picked from commit 8c3383b3462de491430174c561adfbb00431f7f8) --- .../Common/Include/Common/Math/Quaternion.h | 22 +- .../Source/Common/Include/Common/Math/Rect.h | 2 +- .../Common/Include/Common/Math/Transform.h | 2 +- .../Common/Include/Common/Math/Vector.h | 4 +- .../Source/Common/Include/Common/Math/View.h | 2 +- Engine/Source/Common/Src/Math/Color.cpp | 15 +- Engine/Source/Common/Test/MathTest.cpp | 487 +++++++++++++++++- 7 files changed, 504 insertions(+), 30 deletions(-) diff --git a/Engine/Source/Common/Include/Common/Math/Quaternion.h b/Engine/Source/Common/Include/Common/Math/Quaternion.h index 241d0275..87852748 100644 --- a/Engine/Source/Common/Include/Common/Math/Quaternion.h +++ b/Engine/Source/Common/Include/Common/Math/Quaternion.h @@ -84,7 +84,7 @@ namespace Common { Quaternion& operator+=(const Quaternion& rhs); Quaternion& operator-=(const Quaternion& rhs); - Quaternion& operator*=(T rhs) const; + Quaternion& operator*=(T rhs); Quaternion& operator*=(const Quaternion& rhs); Quaternion& operator/=(T rhs); @@ -97,6 +97,9 @@ namespace Common { // when axis faced to us, ccw as positive direction Vec RotateVector(const Vec& inVector) const; Mat GetRotationMatrix() const; + + template + Quaternion CastTo() const; }; template @@ -541,7 +544,7 @@ namespace Common { } template - Quaternion& Quaternion::operator*=(T rhs) const + Quaternion& Quaternion::operator*=(T rhs) { this->w *= rhs; this->x *= rhs; @@ -553,7 +556,7 @@ namespace Common { template Quaternion& Quaternion::operator*=(const Quaternion& rhs) { - *this = this * rhs; + *this = *this * rhs; return *this; } @@ -610,7 +613,7 @@ namespace Common { template T Quaternion::Dot(const Quaternion& rhs) const { - return this->w * rhs.w * this->x * rhs.x + this->y * rhs.y + this->z * rhs.z; + return this->w * rhs.w + this->x * rhs.x + this->y * rhs.y + this->z * rhs.z; } template @@ -643,4 +646,15 @@ namespace Common { ); } + template + template + Quaternion Quaternion::CastTo() const + { + Quaternion result; + result.w = static_cast(this->w); + result.x = static_cast(this->x); + result.y = static_cast(this->y); + result.z = static_cast(this->z); + return result; + } } diff --git a/Engine/Source/Common/Include/Common/Math/Rect.h b/Engine/Source/Common/Include/Common/Math/Rect.h index a8035371..4974b0dd 100644 --- a/Engine/Source/Common/Include/Common/Math/Rect.h +++ b/Engine/Source/Common/Include/Common/Math/Rect.h @@ -219,7 +219,7 @@ namespace Common { template T Rect::Size() const { - Vec diagonal = this->max - this->min; + Vec diagonal = this->max - this->min; return diagonal.Model() / T(2); } diff --git a/Engine/Source/Common/Include/Common/Math/Transform.h b/Engine/Source/Common/Include/Common/Math/Transform.h index 84c64d08..d3fb18e4 100644 --- a/Engine/Source/Common/Include/Common/Math/Transform.h +++ b/Engine/Source/Common/Include/Common/Math/Transform.h @@ -434,7 +434,7 @@ namespace Common { template Vec Transform::TransformPosition(const Vec& inPosition) const { - Mat posColMat = Mat::FromColVecs(inPosition); + Mat posColMat = Mat::FromColVecs(Vec(inPosition)); return (GetTransformMatrix() * posColMat).Col(0); } diff --git a/Engine/Source/Common/Include/Common/Math/Vector.h b/Engine/Source/Common/Include/Common/Math/Vector.h index 60f94a58..47ee9648 100644 --- a/Engine/Source/Common/Include/Common/Math/Vector.h +++ b/Engine/Source/Common/Include/Common/Math/Vector.h @@ -128,7 +128,7 @@ namespace Common { static const Vec unit; static const Vec negaUnitX; static const Vec negaUnitY; - static const Vec negaUintZ; + static const Vec negaUnitZ; static const Vec negaUnitW; static const Vec negaUnit; }; @@ -492,7 +492,7 @@ namespace Common { const Vec VecConsts::negaUnitY = Vec(0, -1, 0, 0); template - const Vec VecConsts::negaUintZ = Vec(0, 0, -1, 0); + const Vec VecConsts::negaUnitZ = Vec(0, 0, -1, 0); template const Vec VecConsts::negaUnitW = Vec(0, 0, 0, -1); diff --git a/Engine/Source/Common/Include/Common/Math/View.h b/Engine/Source/Common/Include/Common/Math/View.h index ad6cad9b..17c4f79c 100644 --- a/Engine/Source/Common/Include/Common/Math/View.h +++ b/Engine/Source/Common/Include/Common/Math/View.h @@ -73,7 +73,7 @@ namespace Common { template ViewTransform ViewTransform::LookAt(const Vec& inPosition, const Vec& inTargetPosition, const Vec& inUpDirection) { - return Transform::LookAt(inPosition, inTargetPosition, inUpDirection); + return ViewTransform(Transform::LookAt(inPosition, inTargetPosition, inUpDirection)); } template diff --git a/Engine/Source/Common/Src/Math/Color.cpp b/Engine/Source/Common/Src/Math/Color.cpp index a8fc829e..f6bc5824 100644 --- a/Engine/Source/Common/Src/Math/Color.cpp +++ b/Engine/Source/Common/Src/Math/Color.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -59,7 +60,11 @@ namespace Common { std::string Color::ToHexString() const { std::stringstream result; - result << "0x" << std::hex << r << std::hex << g << std::hex << b << std::hex << a; + result << "0x" << std::hex << std::setfill('0') + << std::setw(2) << static_cast(r) + << std::setw(2) << static_cast(g) + << std::setw(2) << static_cast(b) + << std::setw(2) << static_cast(a); return result.str(); } @@ -136,10 +141,10 @@ namespace Common { } const Color ColorConsts::white = Color(255, 255, 255, 255); - const Color ColorConsts::black = Color(0, 0, 0, 1); - const Color ColorConsts::red = Color(1, 0, 0, 1); - const Color ColorConsts::green = Color(0, 1, 0, 1); - const Color ColorConsts::blue = Color(0, 0, 1, 1); + const Color ColorConsts::black = Color(0, 0, 0, 255); + const Color ColorConsts::red = Color(255, 0, 0, 255); + const Color ColorConsts::green = Color(0, 255, 0, 255); + const Color ColorConsts::blue = Color(0, 0, 255, 255); const LinearColor LinearColorConsts::white = LinearColor(1.0f, 1.0f, 1.0f, 1.0f); const LinearColor LinearColorConsts::black = LinearColor(0.0f, 0.0f, 0.0f, 1.0f); diff --git a/Engine/Source/Common/Test/MathTest.cpp b/Engine/Source/Common/Test/MathTest.cpp index e745be6a..d757df48 100644 --- a/Engine/Source/Common/Test/MathTest.cpp +++ b/Engine/Source/Common/Test/MathTest.cpp @@ -19,6 +19,8 @@ using namespace Common; +// ==================================== Common ==================================== + TEST(MathTest, CommonTest) { ASSERT_EQ(DivideAndRoundUp(7, 3), 3); @@ -26,6 +28,16 @@ TEST(MathTest, CommonTest) ASSERT_EQ(DivideAndRoundUp(70, 9), 8); } +TEST(MathTest, CompareNumberTest) +{ + ASSERT_TRUE(CompareNumber(1.0f, 1.0f + epsilon / 2.0f)); + ASSERT_FALSE(CompareNumber(1.0f, 1.1f)); + ASSERT_TRUE(CompareNumber(5, 5)); + ASSERT_FALSE(CompareNumber(5, 6)); +} + +// ==================================== Half ==================================== + TEST(MathTest, HFloatTest) // NOLINT { const HFloat v0 = 1.0f; @@ -54,6 +66,47 @@ TEST(MathTest, HFloatTest) // NOLINT ASSERT_TRUE(v5 == HFloat(std::sqrt(v6))); } +TEST(MathTest, HFloatComparisonTest) // NOLINT +{ + const HFloat a = 1.0f; + const HFloat b = 2.0f; + ASSERT_TRUE(a < b); + ASSERT_TRUE(b > a); + ASSERT_TRUE(a <= HFloat(1.0f)); + ASSERT_TRUE(a >= HFloat(1.0f)); + ASSERT_FALSE(a > b); + ASSERT_TRUE(a != b); + + ASSERT_TRUE((HFloat(1.0f) + HFloat(2.0f)) == 3.0f); + ASSERT_TRUE((HFloat(6.0f) - HFloat(2.0f)) == 4.0f); + ASSERT_TRUE((HFloat(2.0f) * HFloat(3.0f)) == 6.0f); + ASSERT_TRUE((HFloat(6.0f) / HFloat(2.0f)) == 3.0f); + + HFloat c = 1.0f; + c += HFloat(2.0f); + c -= 1.0f; + c *= HFloat(3.0f); + ASSERT_TRUE(c == 6.0f); + c /= 2.0f; + ASSERT_TRUE(c == 3.0f); +} + +TEST(MathTest, HFloatEdgeCaseTest) // NOLINT +{ + ASSERT_TRUE(HFloat(0.0f) == 0.0f); + ASSERT_TRUE(HFloat(-2.5f) == -2.5f); + ASSERT_TRUE(HFloat(-2.5f) < HFloat(0.0f)); + ASSERT_TRUE(HFloat(0.5f) == 0.5f); + ASSERT_TRUE(HFloat(0.25f) == 0.25f); + + // values beyond the half range are clamped to the largest representable finite half (~65504) + const float large = static_cast(HFloat(100000.0f)); + ASSERT_GT(large, 60000.0f); + ASSERT_LT(large, 70000.0f); +} + +// ==================================== Vector ==================================== + TEST(MathTest, FVec1Test) { const FVec1 v0; @@ -241,6 +294,25 @@ TEST(MathTest, HVec4Test) ASSERT_TRUE(v1 == HVec4(1.0f, 0.0f, -1.0f, -2.0f)); } +TEST(MathTest, VecScalarCompareTest) +{ + const FVec3 v0(2.0f); + ASSERT_TRUE(v0 == 2.0f); + ASSERT_TRUE(v0 != 3.0f); + ASSERT_FALSE(FVec3(1, 2, 3) == 1.0f); +} + +TEST(MathTest, VecCastToTest) +{ + const FVec3 v0(1.5f, 2.5f, 3.5f); + const IVec3 v1 = v0.CastTo(); + ASSERT_TRUE(v1 == IVec3(1, 2, 3)); + + const IVec2 v2(1, 2); + const FVec2 v3 = v2.CastTo(); + ASSERT_TRUE(v3 == FVec2(1.0f, 2.0f)); +} + TEST(MathTest, SubVecTest) { const auto v0 = FVec4(1, 2, 3, 4); @@ -301,6 +373,19 @@ TEST(MathTest, VecConstsTest) ASSERT_TRUE(IVec3(0, 0, 1) == IVec3Consts::unitZ); } +TEST(MathTest, VecConstsFullTest) +{ + ASSERT_TRUE(FVec4Consts::negaUnitZ == FVec4(0, 0, -1, 0)); + ASSERT_TRUE(FVec4Consts::unitW == FVec4(0, 0, 0, 1)); + ASSERT_TRUE(FVec4Consts::negaUnit == FVec4(-1, -1, -1, -1)); + ASSERT_TRUE(FVec3Consts::unit == FVec3(1, 1, 1)); + ASSERT_TRUE(FVec2Consts::negaUnitX == FVec2(-1, 0)); + ASSERT_TRUE(IVec1Consts::negaUnit == IVec1(-1)); + ASSERT_TRUE(IVec1Consts::unit == IVec1(1)); +} + +// ==================================== Matrix ==================================== + TEST(MathTest, IMat2x3Test) { IMat2x3 v0(1, 2, 3, 4, 5, 6); @@ -351,22 +436,6 @@ TEST(MathTest, SubMatrixTest) ASSERT_TRUE(m1.Row(2) == FVec3(7, 8, 9)); } -TEST(MathTest, MatExtractionTest) -{ - const FMat4x4 m = { - 0, -2, 0, 7, - 4, 0, 0, 5, - 0, 0, 3, 3, - 7, 5, 3, 1 - }; - - const FTransform trans = FTransform(m); - - ASSERT_TRUE(trans.translation == FVec3(7.0f, 5.0f, 3.0f)); - ASSERT_TRUE(trans.scale == FVec3(4.0f, 2.0f, 3.0f)); - ASSERT_TRUE(trans.rotation == FQuat(0.7071067f, .0f, .0f, .7071067f)); -} - TEST(MathTest, MatViewTest) { const FMat3x3 v0( @@ -427,6 +496,27 @@ TEST(MathTest, MatSetTest) // NOLINT ASSERT_TRUE(v0.Row(1) == FVec4(6, 7, 8, 9)); } +TEST(MathTest, MatFromVecsTest) +{ + const FMat2x3 m0 = FMat2x3::FromRowVecs(FVec3(1, 2, 3), FVec3(4, 5, 6)); + ASSERT_TRUE(m0.Row(0) == FVec3(1, 2, 3)); + ASSERT_TRUE(m0.Row(1) == FVec3(4, 5, 6)); + + const FMat2x3 m1 = FMat2x3::FromColVecs(FVec2(1, 4), FVec2(2, 5), FVec2(3, 6)); + ASSERT_TRUE(m1.Col(0) == FVec2(1, 4)); + ASSERT_TRUE(m1 == m0); +} + +TEST(MathTest, MatCastToTest) +{ + const FMat2x2 m0(1.5f, 2.5f, 3.5f, 4.5f); + const IMat2x2 m1 = m0.CastTo(); + ASSERT_EQ(m1[0], 1); + ASSERT_EQ(m1[1], 2); + ASSERT_EQ(m1[2], 3); + ASSERT_EQ(m1[3], 4); +} + TEST(MathTest, MatMulTest) { const FMat3x4 m0( @@ -508,6 +598,55 @@ TEST(MathTest, MatrixDetInverseTest) ASSERT_TRUE(im2.Col(0) == col2); } +TEST(MathTest, MatCanInverseTest) +{ + const FMat2x2 m0(1, 2, 3, 4); + ASSERT_TRUE(m0.CanInverse()); + + const FMat2x2 m1(1, 2, 2, 4); + ASSERT_FALSE(m1.CanInverse()); +} + +TEST(MathTest, MatScalarArithmeticTest) +{ + FMat2x2 m0(1, 2, 3, 4); + m0 += 1.0f; + ASSERT_TRUE(m0 == FMat2x2(2, 3, 4, 5)); + m0 -= 1.0f; + ASSERT_TRUE(m0 == FMat2x2(1, 2, 3, 4)); + m0 *= 2.0f; + ASSERT_TRUE(m0 == FMat2x2(2, 4, 6, 8)); + m0 /= 2.0f; + ASSERT_TRUE(m0 == FMat2x2(1, 2, 3, 4)); + + const FMat2x2 a(1, 2, 3, 4); + const FMat2x2 b(4, 3, 2, 1); + ASSERT_TRUE(a != b); + ASSERT_TRUE(a != 0.0f); + ASSERT_TRUE((a + b) == FMat2x2(5, 5, 5, 5)); + ASSERT_TRUE((b - a) == FMat2x2(3, 1, -1, -3)); + ASSERT_TRUE((a * 2.0f) == FMat2x2(2, 4, 6, 8)); + ASSERT_TRUE((a / 2.0f) == FMat2x2(0.5f, 1.0f, 1.5f, 2.0f)); +} + +TEST(MathTest, MatExtractionTest) +{ + const FMat4x4 m = { + 0, -2, 0, 7, + 4, 0, 0, 5, + 0, 0, 3, 3, + 7, 5, 3, 1 + }; + + const FTransform trans = FTransform(m); + + ASSERT_TRUE(trans.translation == FVec3(7.0f, 5.0f, 3.0f)); + ASSERT_TRUE(trans.scale == FVec3(4.0f, 2.0f, 3.0f)); + ASSERT_TRUE(trans.rotation == FQuat(0.7071067f, .0f, .0f, .7071067f)); +} + +// ==================================== Quaternion ==================================== + TEST(MathTest, AngleAndRadianTest) { const FAngle v0(90.0f); @@ -517,6 +656,20 @@ TEST(MathTest, AngleAndRadianTest) ASSERT_TRUE(v1.ToAngle() == 45.0f); } +TEST(MathTest, AngleRadianConvertTest) +{ + const FAngle a0(90.0f); + const FRadian r0(a0); + ASSERT_TRUE(r0 == FRadian(pi / 2.0f)); + + const FRadian r1(pi); + const FAngle a1(r1); + ASSERT_TRUE(a1 == FAngle(180.0f)); + + ASSERT_TRUE(FAngle(45.0f) == FAngle(45.0f)); + ASSERT_FALSE(FRadian(1.0f) == FRadian(2.0f)); +} + TEST(MathTest, QuaternionBasicTest) { const FQuat v0(1, 2, 3, 4); @@ -525,6 +678,46 @@ TEST(MathTest, QuaternionBasicTest) ASSERT_TRUE((v0 * 2 + v1 - v2) == FQuat(3, 6, 8, 11)); } +TEST(MathTest, QuaternionArithmeticTest) +{ + FQuat v0(1, 2, 3, 4); + v0 += FQuat(1, 1, 1, 1); + ASSERT_TRUE(v0 == FQuat(2, 3, 4, 5)); + v0 -= FQuat(1, 1, 1, 1); + ASSERT_TRUE(v0 == FQuat(1, 2, 3, 4)); + v0 *= 2.0f; + ASSERT_TRUE(v0 == FQuat(2, 4, 6, 8)); + v0 /= 2.0f; + ASSERT_TRUE(v0 == FQuat(1, 2, 3, 4)); + + ASSERT_TRUE((FQuat(2, 4, 6, 8) / 2.0f) == FQuat(1, 2, 3, 4)); + + FQuat v1(1, 2, 3, 4); + v1 *= FQuatConsts::identity; + ASSERT_TRUE(v1 == FQuat(1, 2, 3, 4)); + + const FQuat r0(FVec3Consts::unitZ, 90); + const FQuat r1(FVec3Consts::unitY, 45); + FQuat r2 = r0; + r2 *= r1; + ASSERT_TRUE(r2 == (r0 * r1)); +} + +TEST(MathTest, QuaternionPropertiesTest) +{ + const FQuat v0(1, 2, 3, 4); + ASSERT_TRUE(v0.ImaginaryPart() == FVec3(2, 3, 4)); + ASSERT_FLOAT_EQ(v0.Model(), std::sqrt(30.0f)); + ASSERT_TRUE(v0.Negatived() == FQuat(-1, -2, -3, -4)); + ASSERT_TRUE(v0.Conjugated() == FQuat(1, -2, -3, -4)); + + const FQuat v1(2, 0, 0, 0); + ASSERT_TRUE(v1.Normalized() == FQuat(1, 0, 0, 0)); + + const FQuat v2(2, 3, 4, 5); + ASSERT_FLOAT_EQ(v0.Dot(v2), 1.0f * 2 + 2 * 3 + 3 * 4 + 4 * 5); +} + TEST(MathTest, QuaternionRotationTest) { const FQuat v0(FVec3(0, 0, 1), 90); @@ -538,6 +731,17 @@ TEST(MathTest, QuaternionRotationTest) ASSERT_TRUE(v1r0 == FVec3(0, 0, -1)); } +TEST(MathTest, QuaternionRadianTest) +{ + const FQuat v0(FVec3Consts::unitZ, FRadian(pi / 2.0f)); + const FQuat v1(FVec3Consts::unitZ, 90.0f); + ASSERT_TRUE(v0 == v1); + + const FQuat v2 = FQuat::FromEulerZYX(FRadian(0.0f), FRadian(0.0f), FRadian(pi / 2.0f)); + const FQuat v3 = FQuat::FromEulerZYX(0.0f, 0.0f, 90.0f); + ASSERT_TRUE(v2 == v3); +} + TEST(MathTest, EulerRotationTest) { const FQuat v0 = FQuat::FromEulerZYX(0, 0, 90); @@ -566,6 +770,21 @@ TEST(MathTest, QuaternionToRotationMatrixTest) ASSERT_TRUE(v1r0 == FVec3(-1, 0, 0)); } +TEST(MathTest, QuatConstsTest) +{ + ASSERT_TRUE(FQuatConsts::zero == FQuat(0, 0, 0, 0)); + ASSERT_TRUE(FQuatConsts::identity == FQuat(1, 0, 0, 0)); +} + +TEST(MathTest, QuaternionCastToTest) +{ + const FQuat v0(1.0f, 2.0f, 3.0f, 4.0f); + const DQuat v1 = v0.CastTo(); + ASSERT_TRUE(v1 == DQuat(1.0, 2.0, 3.0, 4.0)); +} + +// ==================================== Transform ==================================== + TEST(MathTest, TransformTest) { FVec3 x(1, 0, 0); @@ -591,6 +810,90 @@ TEST(MathTest, TransformTest) } +TEST(MathTest, TransformOperatorsTest) +{ + const FTransform base; + const FQuat rot(FVec3Consts::unitZ, 90); + + const FTransform v0 = base + FVec3(1, 2, 3); + ASSERT_TRUE(v0.translation == FVec3(1, 2, 3)); + ASSERT_TRUE(v0.scale == FVec3(1, 1, 1)); + + const FTransform v1 = base * FVec3(2, 3, 4); + ASSERT_TRUE(v1.scale == FVec3(2, 3, 4)); + + const FTransform v2 = base | rot; + ASSERT_TRUE(v2.rotation == rot); + + FTransform v3 = base; + v3 += FVec3(1, 1, 1); + v3 *= FVec3(2, 2, 2); + v3 |= rot; + ASSERT_TRUE(v3.translation == FVec3(1, 1, 1)); + ASSERT_TRUE(v3.scale == FVec3(2, 2, 2)); + ASSERT_TRUE(v3.rotation == rot); + + FTransform v4 = base; + v4.Translate(FVec3(5, 0, 0)).Scale(FVec3(2, 2, 2)); + ASSERT_TRUE(v4.translation == FVec3(5, 0, 0)); + ASSERT_TRUE(v4.scale == FVec3(2, 2, 2)); +} + +TEST(MathTest, TransformMatrixTest) +{ + const FTransform v0(FVec3(2, 3, 4), FQuatConsts::identity, FVec3(5, 6, 7)); + + const FMat4x4 tm = v0.GetTranslationMatrix(); + ASSERT_TRUE(tm.Col(3) == FVec4(5, 6, 7, 1)); + + const FMat4x4 sm = v0.GetScaleMatrix(); + ASSERT_FLOAT_EQ(sm.At(0, 0), 2.0f); + ASSERT_FLOAT_EQ(sm.At(1, 1), 3.0f); + ASSERT_FLOAT_EQ(sm.At(2, 2), 4.0f); + + ASSERT_TRUE(v0.GetRotationMatrix() == FMat4x4Consts::identity); + + const FVec3 p0 = v0.TransformPosition(FVec3(1, 1, 1)); + ASSERT_TRUE(p0 == FVec3(7, 9, 11)); + + const FVec4 p1 = (v0.GetTransformMatrixNoScale() * FVec4(1, 1, 1, 1)); + const FVec3 p1Xyz = p1.SubVec<0, 1, 2>(); + ASSERT_TRUE(p1Xyz == FVec3(6, 7, 8)); +} + +TEST(MathTest, TransformPositionVec4Test) +{ + const FTransform v0(FVec3(2, 2, 2), FQuatConsts::identity, FVec3(1, 1, 1)); + const FVec4 p0 = v0.TransformPosition(FVec4(1, 1, 1, 1)); + ASSERT_TRUE(p0 == FVec4(3, 3, 3, 1)); +} + +TEST(MathTest, TransformLookAtTest) +{ + FTransform v0; + v0.MoveAndLookTo(FVec3(1, 2, 3), FVec3(4, 2, 3)); + ASSERT_TRUE(v0.translation == FVec3(1, 2, 3)); + + const FTransform v1 = FTransform::LookAt(FVec3(1, 2, 3), FVec3(4, 2, 3)); + ASSERT_TRUE(v0 == v1); + + FTransform v2(FQuatConsts::identity, FVec3(0, 0, 0)); + v2.LookTo(FVec3(1, 0, 0)); + ASSERT_TRUE(v2.translation == FVec3(0, 0, 0)); + ASSERT_TRUE(v2.rotation == FQuat(0.5f, 0.5f, 0.5f, 0.5f)); +} + +TEST(MathTest, TransformCastToTest) +{ + const FTransform v0(FVec3(2, 3, 4), FQuat(1, 0, 0, 0), FVec3(5, 6, 7)); + const DTransform v1 = v0.CastTo(); + ASSERT_TRUE(v1.scale == DVec3(2, 3, 4)); + ASSERT_TRUE(v1.translation == DVec3(5, 6, 7)); + ASSERT_TRUE(v1.rotation == DQuat(1, 0, 0, 0)); +} + +// ==================================== Rect ==================================== + TEST(MathTest, RectTest) { const FRect rect0(0.0f, 0.0f, 2.0f, 1.0f); @@ -604,6 +907,29 @@ TEST(MathTest, RectTest) ASSERT_TRUE(rect0.Distance(rect2) == 2.0f); } +TEST(MathTest, RectGeometryTest) +{ + const FRect v0 = FRect::FromMinExtent(1.0f, 2.0f, 4.0f, 6.0f); + ASSERT_TRUE(v0.min == FVec2(1, 2)); + ASSERT_TRUE(v0.max == FVec2(5, 8)); + ASSERT_TRUE(v0.Extent() == FVec2(4, 6)); + ASSERT_FLOAT_EQ(v0.ExtentX(), 4.0f); + ASSERT_FLOAT_EQ(v0.ExtentY(), 6.0f); + ASSERT_TRUE(v0.Center() == FVec2(3, 5)); + ASSERT_FLOAT_EQ(v0.CenterX(), 3.0f); + ASSERT_FLOAT_EQ(v0.CenterY(), 5.0f); + ASSERT_FLOAT_EQ(v0.Size(), std::sqrt(4.0f * 4 + 6 * 6) / 2.0f); + + const FRect v1 = FRect::FromMinExtent(FVec2(0, 0), FVec2(2, 2)); + ASSERT_TRUE(v1.max == FVec2(2, 2)); + + const IRect v2 = v0.CastTo(); + ASSERT_TRUE(v2.min == IVec2(1, 2)); + ASSERT_TRUE(v2.max == IVec2(5, 8)); +} + +// ==================================== Box ==================================== + TEST(MathTest, BoxTest) { const FBox box0(FVec3(0.0f), FVec3(1.0f)); @@ -616,6 +942,35 @@ TEST(MathTest, BoxTest) ASSERT_TRUE(!box0.Intersect(box2)); } +TEST(MathTest, BoxGeometryTest) +{ + const FBox v0 = FBox::FromMinExtent(1.0f, 2.0f, 3.0f, 2.0f, 4.0f, 6.0f); + ASSERT_TRUE(v0.min == FVec3(1, 2, 3)); + ASSERT_TRUE(v0.max == FVec3(3, 6, 9)); + ASSERT_TRUE(v0.Extent() == FVec3(2, 4, 6)); + ASSERT_FLOAT_EQ(v0.ExtentX(), 2.0f); + ASSERT_FLOAT_EQ(v0.ExtentY(), 4.0f); + ASSERT_FLOAT_EQ(v0.ExtentZ(), 6.0f); + ASSERT_TRUE(v0.Center() == FVec3(2, 4, 6)); + ASSERT_FLOAT_EQ(v0.CenterX(), 2.0f); + ASSERT_FLOAT_EQ(v0.CenterY(), 4.0f); + ASSERT_FLOAT_EQ(v0.CenterZ(), 6.0f); + ASSERT_FLOAT_EQ(v0.Size(), std::sqrt(2.0f * 2 + 4 * 4 + 6 * 6) / 2.0f); + + const FBox v1(FVec3(0), FVec3(2)); + const FBox v2(FVec3(10), FVec3(12)); + ASSERT_FLOAT_EQ(v1.Distance(v2), std::sqrt(300.0f)); + + const FBox v3 = FBox::FromMinExtent(FVec3(0), FVec3(5)); + ASSERT_TRUE(v3.max == FVec3(5)); + + const IBox v4 = v0.CastTo(); + ASSERT_TRUE(v4.min == IVec3(1, 2, 3)); + ASSERT_TRUE(v4.max == IVec3(3, 6, 9)); +} + +// ==================================== Sphere ==================================== + TEST(MathTest, SphereTest) { const FSphere sphere0(FVec3(0.0f), 1.0f); @@ -629,6 +984,106 @@ TEST(MathTest, SphereTest) ASSERT_TRUE(sphere0.Distance(sphere1) == 0.5f); } +TEST(MathTest, SphereGeometryTest) +{ + const FSphere v0(FVec3(0), 1.0f); + const FSphere v1(FVec3(3, 0, 0), 2.0f); + ASSERT_FLOAT_EQ(v0.Distance(v1), 3.0f); + ASSERT_FALSE(v0.Inside(FVec3(2, 0, 0))); + ASSERT_TRUE(v0.Inside(FVec3(0.5f, 0, 0))); + + const FSphere v2(FVec3(1, 2, 3), 1.0f); + const DSphere v3 = v2.CastTo(); + ASSERT_TRUE(v3.center == DVec3(1, 2, 3)); + ASSERT_TRUE(CompareNumber(v3.radius, 1.0)); +} + +// ==================================== Color ==================================== + +TEST(MathTest, ColorConversionTest) +{ + const Color v0(255, 128, 0, 255); + const LinearColor v1 = v0.ToLinearColor(); + ASSERT_TRUE(v1 == LinearColor(1.0f, 128.0f / 255.0f, 0.0f, 1.0f)); + ASSERT_TRUE(v1.ToColor() == v0); + + ASSERT_TRUE(Color(LinearColor(1.0f, 0.0f, 0.0f, 1.0f)) == Color(255, 0, 0, 255)); + ASSERT_TRUE(LinearColor(Color(0, 255, 0, 255)) == LinearColor(0.0f, 1.0f, 0.0f, 1.0f)); + + ASSERT_EQ(Color(255, 128, 0, 255).ToHexString(), "0xff8000ff"); + ASSERT_EQ(Color(0, 0, 0, 0).ToHexString(), "0x00000000"); + + ASSERT_TRUE(ColorConsts::white == Color(255, 255, 255, 255)); + ASSERT_TRUE(ColorConsts::black == Color(0, 0, 0, 255)); + ASSERT_TRUE(ColorConsts::red == Color(255, 0, 0, 255)); + ASSERT_TRUE(ColorConsts::green == Color(0, 255, 0, 255)); + ASSERT_TRUE(ColorConsts::blue == Color(0, 0, 255, 255)); + ASSERT_TRUE(LinearColorConsts::white == LinearColor(1.0f, 1.0f, 1.0f, 1.0f)); + ASSERT_TRUE(LinearColorConsts::black == LinearColor(0.0f, 0.0f, 0.0f, 1.0f)); +} + +// ==================================== View ==================================== + +TEST(MathTest, ViewMatrixTest) +{ + const FViewTransform v0; + const FMat4x4 vm0 = v0.GetViewMatrix(); + ASSERT_TRUE((vm0 * FVec4(2, 3, 5, 1)) == FVec4(3, 5, 2, 1)); + + const FViewTransform v1 = FViewTransform::LookAt(FVec3(3, 4, 5), FVec3(0, 0, 0), FVec3Consts::unitZ); + const FMat4x4 vm1 = v1.GetViewMatrix(); + + const FVec4 camInView = vm1 * FVec4(3, 4, 5, 1); + ASSERT_NEAR(camInView.x, 0.0f, 1e-4f); + ASSERT_NEAR(camInView.y, 0.0f, 1e-4f); + ASSERT_NEAR(camInView.z, 0.0f, 1e-4f); + + const FVec4 targetInView = vm1 * FVec4(0, 0, 0, 1); + const FVec3 targetXyz = targetInView.SubVec<0, 1, 2>(); + ASSERT_NEAR(targetXyz.Model(), std::sqrt(50.0f), 1e-4f); +} + +// ==================================== Projection ==================================== + +TEST(MathTest, OrthoProjectionMatrixTest) +{ + const FReversedZOrthoProjection v0(4.0f, 2.0f, 1.0f, 11.0f); + const FMat4x4 m0 = v0.GetProjectionMatrix(); + ASSERT_NEAR((m0 * FVec4(0, 0, 1.0f, 1)).z, 1.0f, 1e-4f); + ASSERT_NEAR((m0 * FVec4(0, 0, 11.0f, 1)).z, 0.0f, 1e-4f); + ASSERT_NEAR((m0 * FVec4(2.0f, 0, 1.0f, 1)).x, 1.0f, 1e-4f); + ASSERT_NEAR((m0 * FVec4(0, 1.0f, 1.0f, 1)).y, 1.0f, 1e-4f); + + const FReversedZOrthoProjection v1(4.0f, 2.0f, 1.0f); + const FMat4x4 m1 = v1.GetProjectionMatrix(); + ASSERT_NEAR((m1 * FVec4(0, 0, 1.0f, 1)).z, 1.0f, 1e-4f); + + ASSERT_TRUE(v0 == FReversedZOrthoProjection(4.0f, 2.0f, 1.0f, 11.0f)); + ASSERT_FALSE(v0 == v1); +} + +TEST(MathTest, PerspectiveProjectionMatrixTest) +{ + const FReversedZPerspectiveProjection v0(90.0f, 2.0f, 2.0f, 1.0f, 11.0f); + const FMat4x4 m0 = v0.GetProjectionMatrix(); + ASSERT_NEAR(m0.At(1, 1), 1.0f, 1e-4f); + + const FVec4 nearClip = m0 * FVec4(0, 0, 1.0f, 1); + ASSERT_NEAR(nearClip.z / nearClip.w, 1.0f, 1e-4f); + const FVec4 farClip = m0 * FVec4(0, 0, 11.0f, 1); + ASSERT_NEAR(farClip.z / farClip.w, 0.0f, 1e-4f); + + const FReversedZPerspectiveProjection v1(90.0f, 2.0f, 2.0f, 1.0f, std::nullopt); + const FMat4x4 m1 = v1.GetProjectionMatrix(); + const FVec4 nearClipInf = m1 * FVec4(0, 0, 1.0f, 1); + ASSERT_NEAR(nearClipInf.z / nearClipInf.w, 1.0f, 1e-4f); + + ASSERT_TRUE(v0 == FReversedZPerspectiveProjection(90.0f, 2.0f, 2.0f, 1.0f, 11.0f)); + ASSERT_FALSE(v0 == FReversedZPerspectiveProjection(60.0f, 2.0f, 2.0f, 1.0f, 11.0f)); +} + +// ==================================== Serialization / String / Json ==================================== + TEST(MathTest, SerializationTest) { // half From d0f1d5abb7ea0813374753f19ab1221b2c176868 Mon Sep 17 00:00:00 2001 From: kindem Date: Sun, 14 Jun 2026 18:49:47 +0800 Subject: [PATCH 03/11] docs: note ATL requirement for Windows builds (cherry picked from commit 0c6dcc21ebf577c867b58983e3990c4f11ee334c) --- ThirdParty/ConanRecipes/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ThirdParty/ConanRecipes/README.md b/ThirdParty/ConanRecipes/README.md index 74c331d8..0d040ae0 100644 --- a/ThirdParty/ConanRecipes/README.md +++ b/ThirdParty/ConanRecipes/README.md @@ -52,6 +52,13 @@ python build_recipes.py --upload \ python build_recipes.py --export-only ``` +## Windows notes + +Some recipes build code that needs ATL (e.g. `atlbase.h`), which a stock Visual +Studio C++ installation does not ship. If a build fails with a missing-ATL +error, open the Visual Studio Installer and add the **C++ ATL** component +(Modify -> Individual components -> "C++ ATL for latest build tools"). + ## CI Recipe changes are validated and published automatically: From 2c38777cb45d2ac69a733d73c2ecb3087a8f0e39 Mon Sep 17 00:00:00 2001 From: FlyAndNotDown Date: Mon, 15 Jun 2026 10:59:42 +0800 Subject: [PATCH 04/11] build: support building conan recipes on Linux Teach build_recipes.py to recognize Linux-x86_64 as a host (it used to sys.exit on Linux) and add Linux-x86_64 to the platforms list of every recipe that can target it: assimp, clipp, debugbreak, libclang, qt and dxc. molten-vk stays macOS-only. Verified on Linux/gcc-13/x86_64 via conan create: clipp, debugbreak, assimp, libclang and qt all build, package and (where applicable) pass their test_package. dxc also gets its packaging fixed -- package() now copies installed/lib on Linux as well as macOS, so libdxcompiler.so is shipped (previously only Windows/macOS were handled, leaving the Linux package without the library it declares). The dxc Linux build itself is not yet verified end-to-end (LLVM-scale source build); the packaging change mirrors the macOS path. --- ThirdParty/ConanRecipes/assimp/conandata.yml | 1 + ThirdParty/ConanRecipes/build_recipes.py | 4 +++- ThirdParty/ConanRecipes/clipp/conandata.yml | 1 + ThirdParty/ConanRecipes/debugbreak/conandata.yml | 1 + ThirdParty/ConanRecipes/dxc/conandata.yml | 1 + ThirdParty/ConanRecipes/dxc/conanfile.py | 4 +++- ThirdParty/ConanRecipes/libclang/conandata.yml | 1 + ThirdParty/ConanRecipes/qt/conandata.yml | 1 + 8 files changed, 12 insertions(+), 2 deletions(-) diff --git a/ThirdParty/ConanRecipes/assimp/conandata.yml b/ThirdParty/ConanRecipes/assimp/conandata.yml index 535c0fad..1e15c358 100644 --- a/ThirdParty/ConanRecipes/assimp/conandata.yml +++ b/ThirdParty/ConanRecipes/assimp/conandata.yml @@ -1,6 +1,7 @@ platforms: - Windows-x86_64 - Macos-armv8 + - Linux-x86_64 sources: "6.0.2-exp": url: "https://github.com/assimp/assimp/archive/refs/tags/v6.0.2.tar.gz" diff --git a/ThirdParty/ConanRecipes/build_recipes.py b/ThirdParty/ConanRecipes/build_recipes.py index b26a6d9d..d673cdb7 100644 --- a/ThirdParty/ConanRecipes/build_recipes.py +++ b/ThirdParty/ConanRecipes/build_recipes.py @@ -31,7 +31,7 @@ import yaml -SUPPORTED_PLATFORMS = ("Windows-x86_64", "Macos-armv8") +SUPPORTED_PLATFORMS = ("Windows-x86_64", "Macos-armv8", "Linux-x86_64") # 'conan graph info' binary states that mean a matching binary already exists (in the local cache or on # a remote) and so needs neither building nor downloading-to-rebuild. We skip 'conan create' for these @@ -46,6 +46,8 @@ def current_platform() -> str: return "Windows-x86_64" if system == "Darwin" and machine in ("arm64", "aarch64"): return "Macos-armv8" + if system == "Linux" and machine in ("amd64", "x86_64"): + return "Linux-x86_64" sys.exit( f"error: unsupported host {system}/{platform.machine()}; " f"only {', '.join(SUPPORTED_PLATFORMS)} are supported" diff --git a/ThirdParty/ConanRecipes/clipp/conandata.yml b/ThirdParty/ConanRecipes/clipp/conandata.yml index caf93cf7..27540263 100644 --- a/ThirdParty/ConanRecipes/clipp/conandata.yml +++ b/ThirdParty/ConanRecipes/clipp/conandata.yml @@ -1,6 +1,7 @@ platforms: - Windows-x86_64 - Macos-armv8 + - Linux-x86_64 sources: "1.2.3-exp": url: "https://github.com/muellan/clipp/archive/refs/tags/v1.2.3.tar.gz" diff --git a/ThirdParty/ConanRecipes/debugbreak/conandata.yml b/ThirdParty/ConanRecipes/debugbreak/conandata.yml index 72c15de5..25655329 100644 --- a/ThirdParty/ConanRecipes/debugbreak/conandata.yml +++ b/ThirdParty/ConanRecipes/debugbreak/conandata.yml @@ -1,6 +1,7 @@ platforms: - Windows-x86_64 - Macos-armv8 + - Linux-x86_64 sources: "1.0-exp": url: "https://github.com/scottt/debugbreak/archive/refs/tags/v1.0.tar.gz" diff --git a/ThirdParty/ConanRecipes/dxc/conandata.yml b/ThirdParty/ConanRecipes/dxc/conandata.yml index 1e16bebc..745c782c 100644 --- a/ThirdParty/ConanRecipes/dxc/conandata.yml +++ b/ThirdParty/ConanRecipes/dxc/conandata.yml @@ -1,6 +1,7 @@ platforms: - Windows-x86_64 - Macos-armv8 + - Linux-x86_64 sources: "1.8.2505.1-exp": commit: "b106a961d09221b3c5bdb37be45b679257da08b8" diff --git a/ThirdParty/ConanRecipes/dxc/conanfile.py b/ThirdParty/ConanRecipes/dxc/conanfile.py index beb4d016..0f4cf8e0 100644 --- a/ThirdParty/ConanRecipes/dxc/conanfile.py +++ b/ThirdParty/ConanRecipes/dxc/conanfile.py @@ -74,7 +74,9 @@ def package(self): copy(self, "d3d12shader.h", os.path.join(self.source_folder, "external", "DirectX-Headers", "include", "directx"), os.path.join(self.package_folder, "include", "dxc")) copy(self, "dxil.dll", os.path.join(self.build_folder, "bin"), os.path.join(self.package_folder, "bin")) copy(self, "dxcompiler.lib", os.path.join(self.build_folder, "lib"), os.path.join(self.package_folder, "lib")) - elif self.settings.os == "Macos": + elif self.settings.os in ("Macos", "Linux"): + # install-distribution drops the shared compiler (libdxcompiler.dylib / + # libdxcompiler.so) under installed/lib on both Unix-likes. copy(self, "*", os.path.join(self.build_folder, "installed", "lib"), os.path.join(self.package_folder, "lib")) def package_info(self): diff --git a/ThirdParty/ConanRecipes/libclang/conandata.yml b/ThirdParty/ConanRecipes/libclang/conandata.yml index b55b8b38..bb38b189 100644 --- a/ThirdParty/ConanRecipes/libclang/conandata.yml +++ b/ThirdParty/ConanRecipes/libclang/conandata.yml @@ -1,6 +1,7 @@ platforms: - Windows-x86_64 - Macos-armv8 + - Linux-x86_64 sources: "22.1.6-exp": Windows-x86_64: diff --git a/ThirdParty/ConanRecipes/qt/conandata.yml b/ThirdParty/ConanRecipes/qt/conandata.yml index 1ad84e2c..f0bf218e 100644 --- a/ThirdParty/ConanRecipes/qt/conandata.yml +++ b/ThirdParty/ConanRecipes/qt/conandata.yml @@ -1,5 +1,6 @@ platforms: - Windows-x86_64 - Macos-armv8 + - Linux-x86_64 versions: - "6.10.3-exp" From 83b3567d4fdfb12fd25ccae30322329a772599f6 Mon Sep 17 00:00:00 2001 From: FlyAndNotDown Date: Mon, 15 Jun 2026 11:53:41 +0800 Subject: [PATCH 05/11] ci: run conan recipes / engine build on Linux too Add an ubuntu-latest leg to both the Build (engine) and Publish Conan Recipes workflows so Linux is exercised in CI alongside Windows and macOS, matching the recipes that now declare Linux-x86_64. The engine's own Linux support is not yet verified, so the Build matrix sets fail-fast: false to keep the Linux leg from cancelling the Windows and macOS legs. Publish already had fail-fast: false. README updated to list Linux among the supported platforms and as a publish target. --- .github/workflows/build.yml | 5 ++++- .github/workflows/publish-conan-recipes.yml | 2 ++ ThirdParty/ConanRecipes/README.md | 8 ++++---- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 681434af..16fe3e73 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,8 +18,11 @@ env: jobs: build: strategy: + # Linux runs alongside Windows/macOS, but the engine's own Linux support is + # not yet verified -- keep a failing Linux leg from cancelling the others. + fail-fast: false matrix: - os: ['windows-2022', 'macOS-latest'] + os: ['windows-2022', 'macOS-latest', 'ubuntu-latest'] runs-on: ${{ matrix.os }} diff --git a/.github/workflows/publish-conan-recipes.yml b/.github/workflows/publish-conan-recipes.yml index 4fcf6b26..a6f575bc 100644 --- a/.github/workflows/publish-conan-recipes.yml +++ b/.github/workflows/publish-conan-recipes.yml @@ -33,6 +33,8 @@ jobs: cppstd: '17' - os: macOS-latest cppstd: gnu17 + - os: ubuntu-latest + cppstd: gnu17 runs-on: ${{ matrix.os }} diff --git a/ThirdParty/ConanRecipes/README.md b/ThirdParty/ConanRecipes/README.md index 0d040ae0..084b79c7 100644 --- a/ThirdParty/ConanRecipes/README.md +++ b/ThirdParty/ConanRecipes/README.md @@ -30,8 +30,8 @@ To build every recipe at once, use the `build_recipes.py` helper. It walks each recipe directory, picks the latest version (the top-most entry in `conandata.yml`) and builds them one-by-one in dependency order. Each recipe lists the platforms it supports under a `platforms` key in its `conandata.yml` -(currently `Windows-x86_64` and/or `Macos-armv8`); recipes that do not target -the current host are skipped. If any recipe fails the script stops immediately +(currently `Windows-x86_64`, `Macos-armv8` and/or `Linux-x86_64`); recipes that +do not target the current host are skipped. If any recipe fails the script stops immediately and prints a summary. ```shell @@ -70,8 +70,8 @@ Recipe changes are validated and published automatically: the job. A PR can therefore change recipes and engine code together and be validated atomically. - After a push to `master` that touches `ThirdParty/ConanRecipes`, the - `Publish Conan Recipes` workflow builds the changed recipes on Windows - and macOS and uploads them to the remote (credentials come from the + `Publish Conan Recipes` workflow builds the changed recipes on Windows, + macOS and Linux and uploads them to the remote (credentials come from the `CONAN_REMOTE_USER` / `CONAN_REMOTE_PASSWORD` repository secrets). It can also be re-run manually via `workflow_dispatch` if an upload failed. From 2bb0b843a509b32a582c770a3d58df9272fbd870 Mon Sep 17 00:00:00 2001 From: FlyAndNotDown Date: Wed, 17 Jun 2026 21:56:23 +0800 Subject: [PATCH 06/11] build: install Qt's Linux system dependencies in CI The Qt prebuilt needs GL dev files for CMake's FindOpenGL (Qt6::Gui pulls in WrapOpenGL) at configure time, plus QtWebEngine's Chromium runtime libs to load libQt6WebEngineCore. Hosted runners ship only the GL runtime libraries, so the qt recipe's test_package failed to configure (and would fail to run) on Linux. Install libgl1-mesa-dev/libglu1-mesa-dev on both the build and publish Linux legs; publish additionally installs libnss3/libnspr4/libxkbfile1/libasound2t64 for the WebEngine smoke test. Run that test headlessly from the test_package itself via QT_QPA_PLATFORM=offscreen and QTWEBENGINE_DISABLE_SANDBOX=1. --- .github/workflows/build.yml | 8 ++++++++ .github/workflows/publish-conan-recipes.yml | 14 ++++++++++++++ .../ConanRecipes/qt/test_package/conanfile.py | 4 ++++ 3 files changed, 26 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 16fe3e73..8843f6a8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,6 +41,14 @@ jobs: uses: ilammy/msvc-dev-cmd@v1 if: runner.os == 'Windows' + # Qt's prebuilt Qt6::Gui transitively requires WrapOpenGL, so CMake's FindOpenGL needs the GL dev + # files (libOpenGL.so, libGLX.so, GL/gl.h). The runners only ship the runtime libraries by default. + - name: Setup Linux Dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev + if: runner.os == 'Linux' + - name: Checkout Repo uses: actions/checkout@v4 diff --git a/.github/workflows/publish-conan-recipes.yml b/.github/workflows/publish-conan-recipes.yml index a6f575bc..09d3b73b 100644 --- a/.github/workflows/publish-conan-recipes.yml +++ b/.github/workflows/publish-conan-recipes.yml @@ -47,6 +47,20 @@ jobs: uses: ilammy/msvc-dev-cmd@v1 if: runner.os == 'Windows' + # The Linux leg runs Qt's test_package, a headless QWebEngineView smoke test, which needs: + # - GL dev files (libOpenGL.so, libGLX.so, GL/gl.h) for CMake's FindOpenGL at configure time, since + # Qt6::Gui transitively requires WrapOpenGL; runners ship only the GL runtime libraries. + # - QtWebEngine's Chromium runtime libs (NSS/NSPR, ALSA, libxkbfile) so libQt6WebEngineCore loads. + # The headless run env (QT_QPA_PLATFORM=offscreen, QTWEBENGINE_DISABLE_SANDBOX=1) is set by the qt + # recipe's test_package itself, so it is not configured here. + - name: Setup Linux Dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libgl1-mesa-dev libglu1-mesa-dev \ + libnss3 libnspr4 libxkbfile1 libasound2t64 + if: runner.os == 'Linux' + - name: Checkout Repo uses: actions/checkout@v4 diff --git a/ThirdParty/ConanRecipes/qt/test_package/conanfile.py b/ThirdParty/ConanRecipes/qt/test_package/conanfile.py index 04c30ffc..fc862d4b 100644 --- a/ThirdParty/ConanRecipes/qt/test_package/conanfile.py +++ b/ThirdParty/ConanRecipes/qt/test_package/conanfile.py @@ -24,5 +24,9 @@ def test(self): bin_path = os.path.join(self.cpp.build.bindir, "test_package") if self.settings.os == "Macos": self.run(f"open {bin_path}.app", env="conanrun") + elif self.settings.os == "Linux": + # Run the QWebEngineView smoke test headlessly: offscreen avoids needing an X display, and + # disabling Chromium's sandbox keeps it from aborting under restricted/headless environments. + self.run(f"env QT_QPA_PLATFORM=offscreen QTWEBENGINE_DISABLE_SANDBOX=1 {bin_path}", env="conanrun") else: self.run(bin_path, env="conanrun") From c2d0ffcd6aef5d0be30e5a1be82a4e602568bcfc Mon Sep 17 00:00:00 2001 From: FlyAndNotDown Date: Wed, 17 Jun 2026 22:14:51 +0800 Subject: [PATCH 07/11] build: distinguish local-cache from remote when skipping conan recipes The skip-existing pre-check treated 'conan graph info' binary state Cache as "already on remote", but Cache means the binary is in the local cache and, once the cache resolves a recipe, graph info never even queries the remote. So a recipe built locally was reported as published and, under --upload, parked in the present bucket and never uploaded. Resolve the recipe revision and package id via graph info, then ask the local cache and the remote separately (via 'conan list') whether that exact binary exists. A recipe found on the remote is skipped and not re-uploaded; one found only locally skips the rebuild but is still uploaded so the local binary reaches the remote; anything else is built. Any uncertainty falls through to a build. --- ThirdParty/ConanRecipes/build_recipes.py | 135 +++++++++++++++-------- 1 file changed, 90 insertions(+), 45 deletions(-) diff --git a/ThirdParty/ConanRecipes/build_recipes.py b/ThirdParty/ConanRecipes/build_recipes.py index d673cdb7..a5f433ee 100644 --- a/ThirdParty/ConanRecipes/build_recipes.py +++ b/ThirdParty/ConanRecipes/build_recipes.py @@ -11,10 +11,12 @@ from the local cache and builds whatever the remote has no binaries for. CI uses this to validate recipe changes together with the engine build. -Before building each recipe the script exports it and runs 'conan graph info' to -see whether a matching binary already exists on the remote. If so the recipe is -skipped entirely -- no download, no rebuild, and no no-op re-upload. Pass ---no-skip-existing to force every supported recipe through 'conan create'. +Before building each recipe the script exports it, resolves the package id for the +current profile, then asks both the local cache and the remote whether that exact +recipe-revision + package-id binary already exists. A recipe found on the remote is +skipped with no download, rebuild, or no-op re-upload; one found only in the local +cache skips the rebuild but is still uploaded so the local binary reaches the remote. +Pass --no-skip-existing to force every supported recipe through 'conan create'. """ from __future__ import annotations @@ -33,11 +35,6 @@ SUPPORTED_PLATFORMS = ("Windows-x86_64", "Macos-armv8", "Linux-x86_64") -# 'conan graph info' binary states that mean a matching binary already exists (in the local cache or on -# a remote) and so needs neither building nor downloading-to-rebuild. We skip 'conan create' for these -# so an unchanged recipe is never pulled from the remote just to be re-uploaded as a no-op. -ALREADY_AVAILABLE_BINARY_STATES = {"Cache", "Download", "Update"} - def current_platform() -> str: system = platform.system() @@ -135,27 +132,57 @@ def run_capture(cmd: list[str], cwd: Path | None = None) -> tuple[int, str]: return proc.returncode, proc.stdout -def find_binary_state(graph_json: str, reference: str) -> str | None: +def graph_recipe_node(graph_json: str, reference: str) -> dict | None: try: nodes = json.loads(graph_json).get("graph", {}).get("nodes", {}) except (json.JSONDecodeError, AttributeError): return None for node in nodes.values(): - ref = node.get("ref") or "" - if ref.split("#", 1)[0] == reference: - return node.get("binary") + if (node.get("ref") or "").split("#", 1)[0] == reference: + return node return None -def remote_already_has(args: argparse.Namespace, recipe: Recipe, create_extra: list[str]) -> bool: - # Export so the local recipe revision (a hash of the recipe's contents) lands in the cache; for an - # unchanged recipe it equals the revision already on the remote, letting 'conan graph info' resolve - # the matching binary. Any uncertainty -- export/query failure, unparseable output, an unexpected - # state -- falls through to a normal 'conan create'; the pre-check only ever short-circuits a sure - # hit, never a guess. +def list_has_binary(list_json: str, reference: str, package_id: str) -> bool: + # 'conan list' reports each scope (the local cache or a remote) as either an {"error": ...} payload + # when the reference is absent, or a revisions -> packages tree when it is present; the binary exists + # there only if our package id shows up under some revision of that tree. + name_version = reference.split("#", 1)[0] + try: + data = json.loads(list_json) + except json.JSONDecodeError: + return False + for scope in data.values(): + if not isinstance(scope, dict) or "error" in scope: + continue + revisions = (scope.get(name_version) or {}).get("revisions") or {} + for revision in revisions.values(): + if package_id in (revision.get("packages") or {}): + return True + return False + + +def conan_list_has(args: argparse.Namespace, reference: str, package_id: str, remote: str | None) -> bool: + cmd = [args.conan, "list", f"{reference}:{package_id}", "--format=json"] + if remote: + cmd += ["-r", remote] + code, out = run_capture(cmd, cwd=args.recipes_root) + if code != 0: + return False + return list_has_binary(out, reference, package_id) + + +def package_status(args: argparse.Namespace, recipe: Recipe, create_extra: list[str]) -> str: + # Export so the local recipe revision (a hash of the recipe's contents) lands in the cache, then + # resolve the package id for the current profile via 'conan graph info'. With both in hand, query the + # local cache and (when a remote is configured) the remote for that exact recipe-revision + + # package-id binary -- 'conan graph info' alone cannot tell the two apart, since a cache hit masks the + # remote. Returns "remote" if published, "local" if only cached locally, else "build". Any + # uncertainty -- a failed export/query, unparseable output, a missing id -- yields "build", so the + # pre-check only ever short-circuits a sure hit, never a guess. export = [args.conan, "export", f"{recipe.dir_name}/conanfile.py", "--version", recipe.version] if run(export, cwd=args.recipes_root) != 0: - return False + return "build" info = [args.conan, "graph", "info", f"--requires={recipe.reference}", "--format=json"] if args.remote: @@ -163,12 +190,19 @@ def remote_already_has(args: argparse.Namespace, recipe: Recipe, create_extra: l info += create_extra code, out = run_capture(info, cwd=args.recipes_root) if code != 0: - return False + return "build" + + node = graph_recipe_node(out, recipe.reference) + reference = (node or {}).get("ref") or "" + package_id = (node or {}).get("package_id") + if "#" not in reference or not package_id: + return "build" - state = find_binary_state(out, recipe.reference) - if state: - print(f" remote binary state: {state}", flush=True) - return state in ALREADY_AVAILABLE_BINARY_STATES + if args.remote and conan_list_has(args, reference, package_id, args.remote): + return "remote" + if conan_list_has(args, reference, package_id, None): + return "local" + return "build" def parse_args() -> argparse.Namespace: @@ -254,6 +288,7 @@ def export_all(args: argparse.Namespace, recipes: list[Recipe]): def build_all(args: argparse.Namespace, recipes: list[Recipe], host: str): built: list[Recipe] = [] skipped: list[tuple[Recipe, str]] = [] + local: list[Recipe] = [] present: list[Recipe] = [] create_extra: list[str] = [] @@ -264,7 +299,7 @@ def build_all(args: argparse.Namespace, recipes: list[Recipe], host: str): for index, recipe in enumerate(recipes, start=1): header = f"[{index}/{len(recipes)}] {recipe.name}" if recipe.version is None: - fail(args, built, skipped, present, recipe, + fail(args, built, skipped, local, present, recipe, f"could not determine latest version from {recipe.conandata}") if not recipe.supports(host): reason = f"not built on {host} (platforms: {recipe.platforms or 'none'})" @@ -272,10 +307,17 @@ def build_all(args: argparse.Namespace, recipes: list[Recipe], host: str): skipped.append((recipe, reason)) continue - if args.skip_existing and remote_already_has(args, recipe, create_extra): - print(f"\n=== {header} -- {recipe.reference} already on remote, skipping ===", flush=True) - present.append(recipe) - continue + if args.skip_existing: + status = package_status(args, recipe, create_extra) + if status == "remote": + print(f"\n=== {header} -- {recipe.reference} already on remote, skipping ===", flush=True) + present.append(recipe) + continue + if status == "local": + print(f"\n=== {header} -- {recipe.reference} only in local cache, skipping build ===", + flush=True) + local.append(recipe) + continue print(f"\n=== {header} -- building {recipe.reference} ===", flush=True) start = time.time() @@ -287,17 +329,17 @@ def build_all(args: argparse.Namespace, recipes: list[Recipe], host: str): code = run(cmd, cwd=args.recipes_root) elapsed = time.time() - start if code != 0: - fail(args, built, skipped, present, recipe, + fail(args, built, skipped, local, present, recipe, f"'conan create' exited with code {code} after {elapsed:.0f}s") print(f"--- {recipe.reference} built in {elapsed:.0f}s ---", flush=True) built.append(recipe) - return built, skipped, present + return built, skipped, local, present -def fail(args, built, skipped, present, recipe: Recipe, message: str): +def fail(args, built, skipped, local, present, recipe: Recipe, message: str): print(f"\n!!! BUILD FAILED: {recipe.name} -- {message}", file=sys.stderr, flush=True) - print_summary(built, skipped, present, failed=recipe) + print_summary(built, skipped, local, present, failed=recipe) if args.upload: print("\nUpload skipped: not all recipes built successfully.", flush=True) sys.exit(1) @@ -318,16 +360,16 @@ def configure_remote(args: argparse.Namespace): sys.exit("error: failed to log in to remote") -def upload_all(args: argparse.Namespace, built: list[Recipe]): +def upload_all(args: argparse.Namespace, recipes: list[Recipe]): print("\n=== uploading packages ===", flush=True) - for recipe in built: + for recipe in recipes: print(f"\n--- uploading {recipe.reference} ---", flush=True) if run([args.conan, "upload", recipe.reference, "-r", args.remote, "--confirm"]) != 0: sys.exit(f"error: failed to upload {recipe.reference}") - print(f"\nUploaded {len(built)} package(s) to '{args.remote}'.", flush=True) + print(f"\nUploaded {len(recipes)} package(s) to '{args.remote}'.", flush=True) -def print_summary(built, skipped, present, failed: Recipe | None = None): +def print_summary(built, skipped, local, present, failed: Recipe | None = None): print("\n" + "=" * 60, flush=True) print("SUMMARY", flush=True) print("=" * 60, flush=True) @@ -335,13 +377,15 @@ def print_summary(built, skipped, present, failed: Recipe | None = None): print(f" [OK] {recipe.reference}", flush=True) for recipe in present: print(f" [REMOTE] {recipe.reference} (already published)", flush=True) + for recipe in local: + print(f" [LOCAL] {recipe.reference} (only in local cache)", flush=True) for recipe, reason in skipped: print(f" [SKIP] {recipe.name} ({reason})", flush=True) if failed is not None: print(f" [FAILED] {failed.reference}", flush=True) print( - f"\nbuilt: {len(built)} on-remote: {len(present)} skipped: {len(skipped)}" - + (" failed: 1" if failed is not None else ""), + f"\nbuilt: {len(built)} on-remote: {len(present)} local-only: {len(local)} " + f"skipped: {len(skipped)}" + (" failed: 1" if failed is not None else ""), flush=True, ) @@ -374,14 +418,15 @@ def main(): if args.upload: configure_remote(args) - built, skipped, present = build_all(args, recipes, host) - print_summary(built, skipped, present) + built, skipped, local, present = build_all(args, recipes, host) + print_summary(built, skipped, local, present) if args.upload: - if not built: - print("\nNothing to upload; every built recipe was already on the remote.", flush=True) + to_upload = built + local + if not to_upload: + print("\nNothing to upload; every recipe was already on the remote.", flush=True) else: - upload_all(args, built) + upload_all(args, to_upload) print("\nAll done.", flush=True) From a30383d886a11a76279debb4f2a6f57d1163435e Mon Sep 17 00:00:00 2001 From: FlyAndNotDown Date: Wed, 17 Jun 2026 23:41:08 +0800 Subject: [PATCH 08/11] build: fix Linux CMake configure for npm lookup and OpenGL imported config Two Linux-only configure failures: - Editor only searched for npm.cmd/npm without guarding the platform. On WSL the PATH leaks Windows' npm.cmd, which sh cannot execute ("Unterminated quoted string"). Look up npm.cmd on Windows only; other platforms use plain npm. - CMAKE_MAP_IMPORTED_CONFIG_* mapped the non-Release configs to "Release" with no empty (suffix-less) fallback, so CMake refused the configuration-less IMPORTED_LOCATION that FindOpenGL sets on OpenGL::OpenGL / OpenGL::GLX, failing generate with "IMPORTED_LOCATION not set ... configuration Debug". Append the empty element so conan's Release artifacts are still preferred while system imported targets keep resolving. --- CMakeLists.txt | 10 +++++++--- Editor/CMakeLists.txt | 6 +++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e2d1bb8c..f9a742e6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,9 +15,13 @@ set(SUB_PROJECT_VERSION_PATCH 1 CACHE STRING "" FORCE) set(SUB_PROJECT_CMAKE_LIBS "ThirdParty/Registry.cmake" CACHE STRING "" FORCE) set(USE_CONAN ON CACHE BOOL "" FORCE) -set(CMAKE_MAP_IMPORTED_CONFIG_DEBUG "Release" CACHE STRING "" FORCE) -set(CMAKE_MAP_IMPORTED_CONFIG_RELWITHDEBINFO "Release" CACHE STRING "" FORCE) -set(CMAKE_MAP_IMPORTED_CONFIG_MINSIZEREL "Release" CACHE STRING "" FORCE) +# Conan builds its dependencies as Release, so map the other configs onto Release imported locations. The trailing +# empty element keeps the configuration-less IMPORTED_LOCATION as a fallback; system find-modules such as FindOpenGL +# create UNKNOWN imported targets (OpenGL::OpenGL / OpenGL::GLX on Linux) that only set the suffix-less location, and +# without this fallback CMake reports "IMPORTED_LOCATION not set ... configuration Debug" at generate time. +set(CMAKE_MAP_IMPORTED_CONFIG_DEBUG "Release;" CACHE STRING "" FORCE) +set(CMAKE_MAP_IMPORTED_CONFIG_RELWITHDEBINFO "Release;" CACHE STRING "" FORCE) +set(CMAKE_MAP_IMPORTED_CONFIG_MINSIZEREL "Release;" CACHE STRING "" FORCE) add_compile_definitions(BUILD_EDITOR=$) diff --git a/Editor/CMakeLists.txt b/Editor/CMakeLists.txt index 02d6c5e5..c25b9a9e 100644 --- a/Editor/CMakeLists.txt +++ b/Editor/CMakeLists.txt @@ -124,7 +124,11 @@ exp_add_resources_copy_command( # ---- end shaders ----------------------------------------------------------------------------------- # ---- begin web project ----------------------------------------------------------------------------- -find_program(npm_executable NAMES npm.cmd npm REQUIRED NO_CACHE) +if (${CMAKE_SYSTEM_NAME} STREQUAL "Windows") + find_program(npm_executable NAMES npm.cmd npm REQUIRED NO_CACHE) +else () + find_program(npm_executable NAMES npm REQUIRED NO_CACHE) +endif () message("Perform web project npm install......") execute_process( COMMAND ${CMAKE_COMMAND} -E env ${npm_executable} install --no-fund --registry=https://registry.npmmirror.com From 22f47417edddd35f1d40d2bb3a6bb5011a59234f Mon Sep 17 00:00:00 2001 From: FlyAndNotDown Date: Thu, 18 Jun 2026 20:30:47 +0800 Subject: [PATCH 09/11] build: fix Linux engine compile/link/runtime errors and get ctest green - Add missing / includes that MSVC pulled in transitively - Undef GCC's predefined bare `linux` macro so the platform enumerator stays valid - Enable CMAKE_POSITION_INDEPENDENT_CODE so static libs link into shared ones on ELF - Feed libclang the host GCC system include dirs (conan libclang ships no builtin headers) and fix the dead `#elif DPLATFORM_LINUX` typo in MirrorTool - Qualify same-named return types (Runtime::EventsObserverDyn/Observer/PlayStatus) to avoid GCC -Wchanges-meaning, and make Scene.h explicit specializations inline instead of the non-standard `static` - Cast to qint64 in Qt JSON serializer to dodge the int64_t ambiguity on LP64 - Name the current thread via pthread_self() in NamedThread to fix the data race that segfaulted Runtime.Test --- CMake/Common.cmake | 4 ++ Editor/Include/Editor/Qt/JsonSerialization.h | 4 +- .../Source/Common/Include/Common/Delegate.h | 1 + .../Source/Common/Include/Common/Platform.h | 6 +++ .../Common/Include/Common/Serialization.h | 1 + Engine/Source/Common/Src/Concurrent.cpp | 5 ++- Engine/Source/Common/Src/String.cpp | 1 + Engine/Source/Runtime/Include/Runtime/ECS.h | 4 +- .../Runtime/Include/Runtime/System/Scene.h | 6 +-- Engine/Source/Runtime/Include/Runtime/World.h | 2 +- Engine/Source/Runtime/Src/ECS.cpp | 4 +- Engine/Source/Runtime/Src/World.cpp | 2 +- Tool/MirrorTool/Src/Parser.cpp | 45 ++++++++++++++++++- 13 files changed, 72 insertions(+), 13 deletions(-) diff --git a/CMake/Common.cmake b/CMake/Common.cmake index 731ab792..e871dd0e 100644 --- a/CMake/Common.cmake +++ b/CMake/Common.cmake @@ -6,6 +6,10 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_UNITY_BUILD ${USE_UNITY_BUILD}) set(CMAKE_EXPORT_COMPILE_COMMANDS ${EXPORT_COMPILE_COMMANDS}) +# Static libraries such as Common get linked into the engine's shared libraries, so on ELF platforms every object must be +# position-independent or the shared link fails with "relocation ... can not be used when making a shared object". +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + if (${CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT}) set(CMAKE_INSTALL_PREFIX ${CMAKE_SOURCE_DIR}/Installed CACHE PATH "" FORCE) endif() diff --git a/Editor/Include/Editor/Qt/JsonSerialization.h b/Editor/Include/Editor/Qt/JsonSerialization.h index a8e296a2..9434dea4 100644 --- a/Editor/Include/Editor/Qt/JsonSerialization.h +++ b/Editor/Include/Editor/Qt/JsonSerialization.h @@ -175,7 +175,7 @@ namespace Editor { struct QtJsonSerializer { static void QtJsonSerialize(QJsonValue& outJsonValue, int64_t inValue) { - outJsonValue = inValue; + outJsonValue = static_cast(inValue); } static void QtJsonDeserialize(const QJsonValue& inJsonValue, int64_t& outValue) @@ -191,7 +191,7 @@ namespace Editor { struct QtJsonSerializer { static void QtJsonSerialize(QJsonValue& outJsonValue, uint64_t inValue) { - outJsonValue = static_cast(inValue); + outJsonValue = static_cast(inValue); } static void QtJsonDeserialize(const QJsonValue& inJsonValue, uint64_t& outValue) diff --git a/Engine/Source/Common/Include/Common/Delegate.h b/Engine/Source/Common/Include/Common/Delegate.h index 02d58565..e938752e 100644 --- a/Engine/Source/Common/Include/Common/Delegate.h +++ b/Engine/Source/Common/Include/Common/Delegate.h @@ -4,6 +4,7 @@ #pragma once +#include #include #include #include diff --git a/Engine/Source/Common/Include/Common/Platform.h b/Engine/Source/Common/Include/Common/Platform.h index 110b73e3..a98c64bd 100644 --- a/Engine/Source/Common/Include/Common/Platform.h +++ b/Engine/Source/Common/Include/Common/Platform.h @@ -6,6 +6,12 @@ #include +// GCC and Clang in GNU mode predefine the bare macro `linux` as 1, which collides with the linux enumerators below. +// Drop it so the lowercase enumerator stays valid; the canonical `__linux__` is unaffected. +#ifdef linux +#undef linux +#endif + namespace Common { enum class DevelopmentPlatform { windows, diff --git a/Engine/Source/Common/Include/Common/Serialization.h b/Engine/Source/Common/Include/Common/Serialization.h index ac48aa5a..897231f7 100644 --- a/Engine/Source/Common/Include/Common/Serialization.h +++ b/Engine/Source/Common/Include/Common/Serialization.h @@ -5,6 +5,7 @@ #pragma once #include +#include #include #include #include diff --git a/Engine/Source/Common/Src/Concurrent.cpp b/Engine/Source/Common/Src/Concurrent.cpp index 34dfbcb7..c0c3b088 100644 --- a/Engine/Source/Common/Src/Concurrent.cpp +++ b/Engine/Source/Common/Src/Concurrent.cpp @@ -26,7 +26,10 @@ namespace Common { #elif PLATFORM_MACOS pthread_setname_np(name.c_str()); #else - pthread_setname_np(thread.native_handle(), name.c_str()); + // SetThreadName always runs inside the freshly spawned thread, so name the current thread via pthread_self(); + // reading thread.native_handle() here would race with the std::thread move-assignment in the constructor and + // could observe a null handle, crashing pthread_setname_np. + pthread_setname_np(pthread_self(), name.c_str()); #endif } diff --git a/Engine/Source/Common/Src/String.cpp b/Engine/Source/Common/Src/String.cpp index 07321173..b0a82f8a 100644 --- a/Engine/Source/Common/Src/String.cpp +++ b/Engine/Source/Common/Src/String.cpp @@ -2,6 +2,7 @@ // Created by johnk on 2024/4/14. // +#include #include #include #include diff --git a/Engine/Source/Runtime/Include/Runtime/ECS.h b/Engine/Source/Runtime/Include/Runtime/ECS.h index 776a0146..542fbb49 100644 --- a/Engine/Source/Runtime/Include/Runtime/ECS.h +++ b/Engine/Source/Runtime/Include/Runtime/ECS.h @@ -499,7 +499,7 @@ namespace Runtime { Runtime::RuntimeView RuntimeView(const RuntimeFilter& inFilter); Runtime::ConstRuntimeView RuntimeView(const RuntimeFilter& inFilter) const; Runtime::ConstRuntimeView ConstRuntimeView(const RuntimeFilter& inFilter) const; - EventsObserverDyn EventsObserverDyn(CompClass inClass); + Runtime::EventsObserverDyn EventsObserverDyn(CompClass inClass); // global component static template G& GEmplace(Args&&... inArgs); @@ -530,7 +530,7 @@ namespace Runtime { size_t GCompCount() const; // comp observer - Observer Observer(); + Runtime::Observer Observer(); // serialization void Save(ECArchive& outArchive) const; diff --git a/Engine/Source/Runtime/Include/Runtime/System/Scene.h b/Engine/Source/Runtime/Include/Runtime/System/Scene.h index 5bf203d3..8527705d 100644 --- a/Engine/Source/Runtime/Include/Runtime/System/Scene.h +++ b/Engine/Source/Runtime/Include/Runtime/System/Scene.h @@ -65,7 +65,7 @@ namespace Runtime::Internal { namespace Runtime::Internal { template <> - static void UpdateSceneProxyContent(Render::LightSceneProxy& outSceneProxy, const DirectionalLight& inComponent) + inline void UpdateSceneProxyContent(Render::LightSceneProxy& outSceneProxy, const DirectionalLight& inComponent) { outSceneProxy.type = Render::LightType::directional; outSceneProxy.color = inComponent.color; @@ -73,7 +73,7 @@ namespace Runtime::Internal { } template <> - static void UpdateSceneProxyContent(Render::LightSceneProxy& outSceneProxy, const PointLight& inComponent) + inline void UpdateSceneProxyContent(Render::LightSceneProxy& outSceneProxy, const PointLight& inComponent) { outSceneProxy.type = Render::LightType::point; outSceneProxy.color = inComponent.color; @@ -82,7 +82,7 @@ namespace Runtime::Internal { } template <> - static void UpdateSceneProxyContent(Render::LightSceneProxy& outSceneProxy, const SpotLight& inComponent) + inline void UpdateSceneProxyContent(Render::LightSceneProxy& outSceneProxy, const SpotLight& inComponent) { outSceneProxy.type = Render::LightType::spot; outSceneProxy.color = inComponent.color; diff --git a/Engine/Source/Runtime/Include/Runtime/World.h b/Engine/Source/Runtime/Include/Runtime/World.h index 6162fadf..b229f5e2 100644 --- a/Engine/Source/Runtime/Include/Runtime/World.h +++ b/Engine/Source/Runtime/Include/Runtime/World.h @@ -28,7 +28,7 @@ namespace Runtime { World(std::string inName, Client* inClient, PlayType inPlayType); void SetSystemGraph(const SystemGraph& inSystemGraph); - PlayStatus PlayStatus() const; + Runtime::PlayStatus PlayStatus() const; bool Stopped() const; bool Playing() const; bool Paused() const; diff --git a/Engine/Source/Runtime/Src/ECS.cpp b/Engine/Source/Runtime/Src/ECS.cpp index 7335ee8c..55a4afac 100644 --- a/Engine/Source/Runtime/Src/ECS.cpp +++ b/Engine/Source/Runtime/Src/ECS.cpp @@ -763,7 +763,7 @@ namespace Runtime { return Runtime::ConstRuntimeView { *this, inFilter }; } - EventsObserverDyn ECRegistry::EventsObserverDyn(CompClass inClass) + Runtime::EventsObserverDyn ECRegistry::EventsObserverDyn(CompClass inClass) { return Runtime::EventsObserverDyn { *this, inClass }; } @@ -795,7 +795,7 @@ namespace Runtime { iter->second.onRemove.Broadcast(*this, inEntity); } - Observer ECRegistry::Observer() + Runtime::Observer ECRegistry::Observer() { return Runtime::Observer { *this }; } diff --git a/Engine/Source/Runtime/Src/World.cpp b/Engine/Source/Runtime/Src/World.cpp index d8ef1703..9fa201ae 100644 --- a/Engine/Source/Runtime/Src/World.cpp +++ b/Engine/Source/Runtime/Src/World.cpp @@ -29,7 +29,7 @@ namespace Runtime { systemGraph = inSystemGraph; } - PlayStatus World::PlayStatus() const + Runtime::PlayStatus World::PlayStatus() const { return playStatus; } diff --git a/Tool/MirrorTool/Src/Parser.cpp b/Tool/MirrorTool/Src/Parser.cpp index 38518e57..5ea6d07e 100644 --- a/Tool/MirrorTool/Src/Parser.cpp +++ b/Tool/MirrorTool/Src/Parser.cpp @@ -4,6 +4,8 @@ #include #include +#include +#include #include #include @@ -413,6 +415,41 @@ namespace MirrorTool { } namespace MirrorTool { +#if PLATFORM_LINUX + // The conan libclang package ships only libclang.so, without clang's builtin headers (stddef.h, stdarg.h, ...) or any + // system include paths, so query the host GCC for the directories it searches and feed them to clang verbatim. + static std::vector GetHostSystemIncludeDirs() + { + FILE* pipe = popen("g++ -E -x c++ - -v < /dev/null 2>&1", "r"); + if (pipe == nullptr) { + return {}; + } + + std::string output; + std::array buffer {}; + while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) { + output += buffer.data(); + } + pclose(pipe); + + std::vector result; + bool withinSearchList = false; + for (const std::string& line : Common::StringUtils::Split(output, "\n")) { + if (line.find("#include <...> search starts here:") != std::string::npos) { + withinSearchList = true; + } else if (line.find("End of search list.") != std::string::npos) { + break; + } else if (withinSearchList) { + const size_t start = line.find_first_not_of(" \t"); + if (start != std::string::npos) { + result.emplace_back(line.substr(start)); + } + } + } + return result; + } +#endif + Parser::Parser(std::string inSourceFile, std::vector inHeaderDirs, std::vector inFrameworkDirs) : sourceFile(std::move(inSourceFile)) , headerDirs(std::move(inHeaderDirs)) @@ -446,10 +483,16 @@ namespace MirrorTool { std::format("-I/Library/Developer/CommandLineTools/usr/lib/clang/{}/include", __clang_major__), "-I/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include", "-I/Library/Developer/CommandLineTools/usr/include", -#elif DPLATFORM_LINUX +#elif PLATFORM_LINUX "-DPLATFORM_LINUX=1", #endif }; +#if PLATFORM_LINUX + for (const std::string& systemIncludeDir : GetHostSystemIncludeDirs()) { + argumentStrs.emplace_back("-isystem"); + argumentStrs.emplace_back(systemIncludeDir); + } +#endif argumentStrs.reserve(argumentStrs.size() + headerDirs.size()); for (const std::string& headerDir : headerDirs) { argumentStrs.emplace_back(std::string("-I") + headerDir); From 8ea1d73bda867deb8ba80e7ad66b826843f4a0bb Mon Sep 17 00:00:00 2001 From: FlyAndNotDown Date: Thu, 18 Jun 2026 20:44:24 +0800 Subject: [PATCH 10/11] build: fix Linux conan zlib version conflict in assimp recipe The assimp recipe pinned zlib to the exact version 1.3.1, while the rest of the dependency graph requires zlib via the range [>=1.3.1 <2]. Now that conancenter publishes zlib 1.3.2, that range resolves to 1.3.2 and conflicts with assimp's hard pin, breaking 'conan install' at CMake configure time. Use the same range so assimp follows the graph's resolution. --- ThirdParty/ConanRecipes/assimp/conanfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ThirdParty/ConanRecipes/assimp/conanfile.py b/ThirdParty/ConanRecipes/assimp/conanfile.py index a48a9206..8f8ed35e 100644 --- a/ThirdParty/ConanRecipes/assimp/conanfile.py +++ b/ThirdParty/ConanRecipes/assimp/conanfile.py @@ -24,7 +24,7 @@ def validate(self): check_min_cppstd(self, 17) def requirements(self): - self.requires("zlib/1.3.1") + self.requires("zlib/[>=1.3.1 <2]") def build_requirements(self): self.tool_requires("ninja/[>=1.12]") From 60b5154adc85ac4e4597e78b2d473040d02c9804 Mon Sep 17 00:00:00 2001 From: FlyAndNotDown Date: Thu, 18 Jun 2026 20:48:16 +0800 Subject: [PATCH 11/11] ci: let Conan install xorg/system's apt packages on Linux xorg/system (pulled in transitively by Qt on Linux) declares a long list of X11 dev packages as system requirements. Conan's default package_manager mode is 'check', so it errored at configure time instead of installing them. Set tools.system.package_manager:mode=install (with sudo) in global.conf on the Linux leg so Conan installs them via apt. --- .github/workflows/build.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8843f6a8..d8395b6c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -73,6 +73,15 @@ jobs: - name: Config Conan Remote run: conan remote add explosion https://conan.kindem.online/artifactory/api/conan/conan + # xorg/system (pulled in transitively by Qt on Linux) declares its X11 dev packages as system + # requirements; let Conan install them via apt instead of erroring out in the default 'check' mode. + - name: Allow Conan System Package Install + run: | + conf="$(conan config home)/global.conf" + echo "tools.system.package_manager:mode=install" >> "$conf" + echo "tools.system.package_manager:sudo=True" >> "$conf" + if: runner.os == 'Linux' + # Register the in-repo recipes in the local cache so the engine's 'conan install --build=missing' # resolves them from this checkout: unchanged recipes hash to the revision already published on the # remote (binaries are downloaded), changed ones get built locally, making recipe PRs self-contained.