From 9f781e8f839e760b8018928975627c0722fff611 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Thu, 16 Jul 2026 16:41:01 +0200 Subject: [PATCH 1/5] Added runtime and viewport controls --- atlas/application/window.cpp | 11 +++ editor/views/editor/editor.cpp | 4 +- editor/views/editor/viewport.cpp | 69 ++++++++++++++ editor/views/editor/viewportTools.cpp | 129 ++++++++++++++++++++++++++ include/atlas/runtime/context.h | 2 + include/atlas/window.h | 9 ++ include/editor/views/viewport.h | 11 +++ include/editor/views/viewportTools.h | 29 ++++++ runtime/lib/context.cpp | 15 +++ 9 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 editor/views/editor/viewportTools.cpp create mode 100644 include/editor/views/viewportTools.h diff --git a/atlas/application/window.cpp b/atlas/application/window.cpp index 9e0a8d36..ed5b4e31 100644 --- a/atlas/application/window.cpp +++ b/atlas/application/window.cpp @@ -2000,6 +2000,17 @@ void Window::setEditorControlMode(EditorControlMode mode) { editorActiveGizmoAxis = 0; } +void Window::setEditorShadingMode(EditorShadingMode mode) { + editorShadingMode = mode; + opal::RasterizerMode nextMode = opal::RasterizerMode::Fill; + if (mode == EditorShadingMode::Wireframe) { + nextMode = opal::RasterizerMode::Line; + } else if (mode == EditorShadingMode::Points) { + nextMode = opal::RasterizerMode::Point; + } + updatePipelineStateField(rasterizerMode, nextMode); +} + unsigned int Window::getSelectedEditorObjectId() const { return selectedEditorObject != nullptr ? selectedEditorObject->getId() : 0; } diff --git a/editor/views/editor/editor.cpp b/editor/views/editor/editor.cpp index 3e0d2b71..adeea9e3 100644 --- a/editor/views/editor/editor.cpp +++ b/editor/views/editor/editor.cpp @@ -26,6 +26,7 @@ #include "editor/views/hierarchyPanel.h" #include "editor/views/inspectorView.h" #include "editor/views/viewport.h" +#include "editor/views/viewportTools.h" EditorWindow::EditorWindow(const QString &projectFile, QWidget *parent) : QMainWindow(parent), projectFile(projectFile) { @@ -102,10 +103,11 @@ void EditorWindow::setupMenus() { void EditorWindow::setupDocks() { viewportPanel = new ViewportPanel(projectFile); + auto *viewportTools = new ViewportTools(viewportPanel); dockManager->addPanel( {.id = "viewport", .title = "Viewport", - .widget = viewportPanel, + .widget = viewportTools, .area = EditorDockArea::Center, .icon = style()->standardIcon(QStyle::SP_DirOpenIcon)}); diff --git a/editor/views/editor/viewport.cpp b/editor/views/editor/viewport.cpp index 4d8c29d1..064aa091 100644 --- a/editor/views/editor/viewport.cpp +++ b/editor/views/editor/viewport.cpp @@ -292,9 +292,12 @@ void ViewportPanel::startRuntime() { runtimeContext->setEditorControlsEnabled(true); runtimeContext->setEditorSimulationEnabled(false); runtimeContext->setEditorControlMode(1); + runtimeContext->setEditorShadingMode(shadingMode); resizeRuntime(); refreshSceneSnapshot(); emit runtimeAvailabilityChanged(true); + playbackState = 0; + emit playbackStateChanged(playbackState); frameTimer->start(16); } catch (const std::exception &error) { qWarning().noquote() @@ -320,6 +323,8 @@ void ViewportPanel::stopRuntime() { auto context = std::move(runtimeContext); lastSceneSnapshot.clear(); emit runtimeAvailabilityChanged(false); + playbackState = 0; + emit playbackStateChanged(playbackState); try { context->end(); } catch (const std::exception &error) { @@ -344,6 +349,7 @@ void ViewportPanel::stepRuntime() { return; } refreshSceneSnapshot(); + emit frameRateChanged(runtimeContext->frameRate()); } catch (const std::exception &error) { qWarning().noquote() << QStringLiteral("Atlas viewport runtime frame failed: %1") @@ -482,6 +488,69 @@ bool ViewportPanel::saveRuntimeScene() { return runtimeContext != nullptr && runtimeContext->saveCurrentScene(); } +void ViewportPanel::playRuntime() { + if (runtimeContext == nullptr) { + return; + } + runtimeContext->setEditorSimulationEnabled(true); + playbackState = 1; + emit playbackStateChanged(playbackState); +} + +void ViewportPanel::pauseRuntime() { + if (runtimeContext == nullptr || playbackState == 0) { + return; + } + runtimeContext->setEditorSimulationEnabled(false); + playbackState = 2; + emit playbackStateChanged(playbackState); +} + +void ViewportPanel::stepRuntimeOnce() { + if (runtimeContext == nullptr || playbackState == 0) { + return; + } + runtimeContext->setEditorSimulationEnabled(true); + stepRuntime(); + if (runtimeContext != nullptr) { + runtimeContext->setEditorSimulationEnabled(false); + playbackState = 2; + emit playbackStateChanged(playbackState); + } +} + +void ViewportPanel::stopRuntimePlayback() { + if (runtimeContext == nullptr || playbackState == 0) { + return; + } + reloadRuntime(); +} + +void ViewportPanel::reloadRuntime() { + if (shuttingDown) { + return; + } + stopRuntime(); + scheduleRuntimeStart(); +} + +void ViewportPanel::setRuntimeShadingMode(int mode) { + if (mode < 0 || mode > 2) { + return; + } + shadingMode = mode; + if (runtimeContext != nullptr) { + runtimeContext->setEditorShadingMode(mode); + } +} + +void ViewportPanel::setRuntimeControlMode(int mode) { + if (mode < 0 || mode > 3 || runtimeContext == nullptr) { + return; + } + runtimeContext->setEditorControlMode(mode); +} + void ViewportPanel::refreshSceneSnapshot() { if (runtimeContext == nullptr) { return; diff --git a/editor/views/editor/viewportTools.cpp b/editor/views/editor/viewportTools.cpp new file mode 100644 index 00000000..d3b4795b --- /dev/null +++ b/editor/views/editor/viewportTools.cpp @@ -0,0 +1,129 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +ViewportTools::ViewportTools(ViewportPanel *viewport, QWidget *parent) + : QWidget(parent), viewport(viewport) { + setObjectName("viewportTools"); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + auto *toolbar = new QWidget(this); + toolbar->setObjectName("viewportToolbar"); + auto *tools = new QHBoxLayout(toolbar); + tools->setContentsMargins(7, 5, 7, 5); + tools->setSpacing(4); + + playButton = new QToolButton(toolbar); + playButton->setObjectName("viewportPlaybackButton"); + playButton->setIcon(style()->standardIcon(QStyle::SP_MediaPlay)); + playButton->setToolTip("Play"); + pauseButton = new QToolButton(toolbar); + pauseButton->setObjectName("viewportPlaybackButton"); + pauseButton->setIcon(style()->standardIcon(QStyle::SP_MediaPause)); + pauseButton->setToolTip("Pause"); + stepButton = new QToolButton(toolbar); + stepButton->setObjectName("viewportPlaybackButton"); + stepButton->setIcon(style()->standardIcon(QStyle::SP_MediaSkipForward)); + stepButton->setToolTip("Step one frame"); + stopButton = new QToolButton(toolbar); + stopButton->setObjectName("viewportPlaybackButton"); + stopButton->setIcon(style()->standardIcon(QStyle::SP_MediaStop)); + stopButton->setToolTip("Stop and restore the scene"); + + tools->addStretch(); + tools->addWidget(playButton); + tools->addWidget(pauseButton); + tools->addWidget(stepButton); + tools->addWidget(stopButton); + tools->addSpacing(10); + + auto *transformGroup = new QActionGroup(toolbar); + transformGroup->setExclusive(true); + const QStringList transformNames{"Move", "Rotate", "Scale"}; + const QStringList transformLabels{"Move", "Rotate", "Scale"}; + for (int index = 0; index < transformNames.size(); ++index) { + auto *button = new QToolButton(toolbar); + button->setObjectName("viewportModeButton"); + button->setText(transformLabels.at(index)); + button->setToolTip(transformNames.at(index) + " tool"); + button->setCheckable(true); + auto *action = new QAction(transformNames.at(index), button); + action->setCheckable(true); + action->setData(index + 1); + button->setDefaultAction(action); + transformGroup->addAction(action); + tools->addWidget(button); + if (index == 0) { + action->setChecked(true); + } + } + + tools->addSpacing(10); + auto *shading = new QComboBox(toolbar); + shading->setObjectName("viewportShadingMode"); + shading->addItems({"Lit", "Wireframe", "Points"}); + shading->setToolTip("Viewport shading"); + tools->addWidget(shading); + + auto *fpsButton = new QToolButton(toolbar); + fpsButton->setObjectName("viewportOptionButton"); + fpsButton->setText("FPS"); + fpsButton->setCheckable(true); + fpsButton->setChecked(true); + fpsButton->setToolTip("Show frame rate"); + fpsLabel = new QLabel("-- FPS", toolbar); + fpsLabel->setObjectName("viewportFpsLabel"); + fpsLabel->setMinimumWidth(62); + tools->addWidget(fpsButton); + tools->addWidget(fpsLabel); + tools->addStretch(); + + layout->addWidget(toolbar); + layout->addWidget(viewport, 1); + + connect(playButton, &QToolButton::clicked, viewport, + &ViewportPanel::playRuntime); + connect(pauseButton, &QToolButton::clicked, viewport, + &ViewportPanel::pauseRuntime); + connect(stepButton, &QToolButton::clicked, viewport, + &ViewportPanel::stepRuntimeOnce); + connect(stopButton, &QToolButton::clicked, viewport, + &ViewportPanel::stopRuntimePlayback); + connect(transformGroup, &QActionGroup::triggered, this, + [viewport](QAction *action) { + viewport->setRuntimeControlMode(action->data().toInt()); + }); + connect(shading, &QComboBox::currentIndexChanged, viewport, + &ViewportPanel::setRuntimeShadingMode); + connect(fpsButton, &QToolButton::toggled, fpsLabel, &QWidget::setVisible); + connect(viewport, &ViewportPanel::frameRateChanged, this, + [this](float fps) { + fpsLabel->setText(QStringLiteral("%1 FPS").arg(fps, 0, 'f', 0)); + }); + connect(viewport, &ViewportPanel::playbackStateChanged, this, + &ViewportTools::updatePlaybackState); + connect(viewport, &ViewportPanel::runtimeAvailabilityChanged, this, + [this](bool available) { + runtimeAvailable = available; + updatePlaybackState(playbackState); + }); + updatePlaybackState(0); +} + +void ViewportTools::updatePlaybackState(int state) { + playbackState = state; + playButton->setEnabled(runtimeAvailable && state != 1); + pauseButton->setEnabled(runtimeAvailable && state == 1); + stepButton->setEnabled(runtimeAvailable && state != 0); + stopButton->setEnabled(runtimeAvailable && state != 0); +} diff --git a/include/atlas/runtime/context.h b/include/atlas/runtime/context.h index 94498cb9..d98cdc16 100644 --- a/include/atlas/runtime/context.h +++ b/include/atlas/runtime/context.h @@ -107,6 +107,8 @@ class Context { bool setEditorControlsEnabled(bool enabled); bool setEditorSimulationEnabled(bool enabled); bool setEditorControlMode(int mode); + bool setEditorShadingMode(int mode); + float frameRate() const; bool editorPointerEvent(int action, float x, float y, int button, float scale); bool editorScrollEvent(float delta, float scale); diff --git a/include/atlas/window.h b/include/atlas/window.h index eb420ed5..26c9425f 100644 --- a/include/atlas/window.h +++ b/include/atlas/window.h @@ -47,6 +47,12 @@ enum class EditorControlMode { Scale = 3, }; +enum class EditorShadingMode { + Lit = 0, + Wireframe = 1, + Points = 2, +}; + /** * @brief Structure representing the configuration options for creating a * window. @@ -438,6 +444,8 @@ class Window { bool isEditorSimulationEnabled() const { return editorSimulationEnabled; } void setEditorControlMode(EditorControlMode mode); EditorControlMode getEditorControlMode() const { return editorControlMode; } + void setEditorShadingMode(EditorShadingMode mode); + EditorShadingMode getEditorShadingMode() const { return editorShadingMode; } void editorPointerEvent(int action, float x, float y, int button, float scale = 1.0f); void editorScrollEvent(float delta, float scale = 1.0f); @@ -980,6 +988,7 @@ class Window { bool editorControlsEnabled = false; bool editorSimulationEnabled = true; EditorControlMode editorControlMode = EditorControlMode::None; + EditorShadingMode editorShadingMode = EditorShadingMode::Lit; GameObject *selectedEditorObject = nullptr; bool editorDragging = false; bool editorCameraDragging = false; diff --git a/include/editor/views/viewport.h b/include/editor/views/viewport.h index d43da4b1..ceabc0c1 100644 --- a/include/editor/views/viewport.h +++ b/include/editor/views/viewport.h @@ -51,11 +51,20 @@ class ViewportPanel : public QWidget { bool deleteRuntimeObject(int id); int createRuntimeObject(const QString &type, const QString &name = {}); bool saveRuntimeScene(); + void playRuntime(); + void pauseRuntime(); + void stepRuntimeOnce(); + void stopRuntimePlayback(); + void reloadRuntime(); + void setRuntimeShadingMode(int mode); + void setRuntimeControlMode(int mode); signals: void sceneSnapshotChanged(const QString &snapshot); void runtimeAvailabilityChanged(bool available); void runtimeObjectActivated(int id); + void playbackStateChanged(int state); + void frameRateChanged(float framesPerSecond); protected: QPaintEngine *paintEngine() const override; @@ -88,6 +97,8 @@ class ViewportPanel : public QWidget { QString lastSceneSnapshot; bool runtimeStartQueued = false; bool shuttingDown = false; + int playbackState = 0; + int shadingMode = 0; }; #endif // ATLAS_VIEWPORT_H diff --git a/include/editor/views/viewportTools.h b/include/editor/views/viewportTools.h new file mode 100644 index 00000000..96148138 --- /dev/null +++ b/include/editor/views/viewportTools.h @@ -0,0 +1,29 @@ +#ifndef ATLAS_VIEWPORTTOOLS_H +#define ATLAS_VIEWPORTTOOLS_H + +#include + +class QLabel; +class QToolButton; +class ViewportPanel; + +class ViewportTools : public QWidget { + Q_OBJECT + + public: + explicit ViewportTools(ViewportPanel *viewport, QWidget *parent = nullptr); + + private: + void updatePlaybackState(int state); + + ViewportPanel *viewport = nullptr; + QToolButton *playButton = nullptr; + QToolButton *pauseButton = nullptr; + QToolButton *stepButton = nullptr; + QToolButton *stopButton = nullptr; + QLabel *fpsLabel = nullptr; + bool runtimeAvailable = false; + int playbackState = 0; +}; + +#endif diff --git a/runtime/lib/context.cpp b/runtime/lib/context.cpp index e29ccd79..c3a90f32 100644 --- a/runtime/lib/context.cpp +++ b/runtime/lib/context.cpp @@ -3975,6 +3975,21 @@ bool Context::setEditorControlMode(int mode) { return true; } +bool Context::setEditorShadingMode(int mode) { + if (window == nullptr) { + throw std::runtime_error("Window is not initialized"); + } + if (mode < 0 || mode > 2) { + return false; + } + window->setEditorShadingMode(static_cast(mode)); + return true; +} + +float Context::frameRate() const { + return window != nullptr ? window->getFramesPerSecond() : 0.0f; +} + bool Context::editorPointerEvent(int action, float x, float y, int button, float scale) { if (window == nullptr) { From 3b518e84bff4fbe7c45891e50afce1add9c71650 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Thu, 16 Jul 2026 16:46:11 +0200 Subject: [PATCH 2/5] Added the material asset editor --- atlas/graphics/deferred.cpp | 6 +- editor/styling/dark.qss | 86 ++++ editor/views/editor/editor.cpp | 11 + editor/views/editor/materialEditor.cpp | 626 ++++++++++++++++++++++++ editor/views/editor/viewportTools.cpp | 12 + editor/views/general/contentBrowser.cpp | 33 ++ include/editor/views/fileExplorer.h | 2 + include/editor/views/materialEditor.h | 67 +++ include/editor/views/viewportTools.h | 1 + 9 files changed, 843 insertions(+), 1 deletion(-) create mode 100644 editor/views/editor/materialEditor.cpp create mode 100644 include/editor/views/materialEditor.h diff --git a/atlas/graphics/deferred.cpp b/atlas/graphics/deferred.cpp index de451e7d..8fd8c89c 100644 --- a/atlas/graphics/deferred.cpp +++ b/atlas/graphics/deferred.cpp @@ -315,6 +315,7 @@ void Window::deferredRendering( std::shared_ptr pipeline; int width = 0; int height = 0; + opal::RasterizerMode rasterizerMode = opal::RasterizerMode::Fill; }; static std::unordered_map deferredPrograms; static std::unordered_map @@ -431,10 +432,12 @@ void Window::deferredRendering( const int gbufferHeight = this->gBuffer->getHeight(); if (pipelineEntry.pipeline == nullptr || pipelineEntry.width != gbufferWidth || - pipelineEntry.height != gbufferHeight) { + pipelineEntry.height != gbufferHeight || + pipelineEntry.rasterizerMode != this->rasterizerMode) { auto deferredPipeline = opal::Pipeline::create(); deferredPipeline->setViewport(0, 0, gbufferWidth, gbufferHeight); deferredPipeline->setCullMode(this->cullMode); + deferredPipeline->setRasterizerMode(this->rasterizerMode); deferredPipeline->setFrontFace(this->deferredFrontFace); deferredPipeline->enableDepthTest(true); deferredPipeline->setDepthCompareOp(opal::CompareOp::Less); @@ -443,6 +446,7 @@ void Window::deferredRendering( programIt->second.requestPipeline(deferredPipeline); pipelineEntry.width = gbufferWidth; pipelineEntry.height = gbufferHeight; + pipelineEntry.rasterizerMode = this->rasterizerMode; } obj->setViewMatrix(this->camera->calculateViewMatrix()); diff --git a/editor/styling/dark.qss b/editor/styling/dark.qss index da9935d0..43882ce8 100644 --- a/editor/styling/dark.qss +++ b/editor/styling/dark.qss @@ -157,6 +157,92 @@ QToolButton::menu-indicator { image: none; } +#viewportToolbar { + background-color: #1B1E20; + border-bottom: 1px solid #353B3E; +} + +#viewportPlaybackButton, +#viewportModeButton, +#viewportOptionButton { + min-width: 26px; + min-height: 24px; + padding: 2px 7px; +} + +#viewportPlaybackButton { + border-radius: 12px; +} + +#viewportShadingMode { + min-width: 108px; + padding-top: 3px; + padding-bottom: 3px; +} + +#viewportFpsLabel { + color: #9DA8AE; + font-variant-numeric: tabular-nums; +} + +#materialEditorHeader { + background-color: #1B1E20; + border-bottom: 1px solid #363C3F; +} + +#materialEditorTitle { + color: #F3F6F7; + font-size: 15px; + font-weight: 650; +} + +#materialEditorStatus { + color: #89959B; +} + +#materialEditorScroll, +#materialEditorBody { + border: none; + background-color: #202324; +} + +#materialPreview { + background-color: #191C1D; + border: 1px solid #3A4144; + border-radius: 8px; +} + +#materialEditorEmpty { + color: #879299; + padding: 40px; +} + +#materialTextureSlot { + background-color: #1B1E20; + border: 1px solid #343B3E; + border-radius: 6px; +} + +#materialTexturePreview { + background-color: #25292B; + border: 1px solid #41494D; + border-radius: 4px; + color: #EF8C7B; + font-weight: 700; + qproperty-alignment: AlignCenter; +} + +#materialTextureLabel { + color: #CDD4D8; + font-weight: 600; +} + +#materialTexturePath { + color: #8F9AA0; + padding-top: 3px; + padding-bottom: 3px; +} + QLineEdit, QTextEdit, QPlainTextEdit, diff --git a/editor/views/editor/editor.cpp b/editor/views/editor/editor.cpp index adeea9e3..14574efd 100644 --- a/editor/views/editor/editor.cpp +++ b/editor/views/editor/editor.cpp @@ -25,6 +25,7 @@ #include "editor/views/fileExplorer.h" #include "editor/views/hierarchyPanel.h" #include "editor/views/inspectorView.h" +#include "editor/views/materialEditor.h" #include "editor/views/viewport.h" #include "editor/views/viewportTools.h" @@ -135,6 +136,14 @@ void EditorWindow::setupDocks() { .area = EditorDockArea::Bottom, .icon = style()->standardIcon(QStyle::SP_DirOpenIcon)}); + auto *materialEditor = new MaterialEditorPanel; + dockManager->addPanel( + {.id = "materialEditor", + .title = "Material Editor", + .widget = materialEditor, + .area = EditorDockArea::Right, + .icon = style()->standardIcon(QStyle::SP_FileDialogContentsView)}); + connect(hierarchyPanel, &HierarchyPanel::objectActivated, inspectorPanel, &InspectorPanel::inspectRuntimeObject); connect(hierarchyPanel, &HierarchyPanel::objectActivated, contentBrowser, @@ -150,6 +159,8 @@ void EditorWindow::setupDocks() { } this->inspectorPanel->inspectFile(path); }); + connect(contentBrowser, &ContentBrowserPanel::assetActivated, + materialEditor, &MaterialEditorPanel::openMaterial); } void EditorWindow::saveLayout() { diff --git a/editor/views/editor/materialEditor.cpp b/editor/views/editor/materialEditor.cpp new file mode 100644 index 00000000..96fbe15c --- /dev/null +++ b/editor/views/editor/materialEditor.cpp @@ -0,0 +1,626 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { +QColor jsonColor(const QJsonValue &value, const QColor &fallback) { + const QJsonArray array = value.toArray(); + if (array.size() < 3) { + return fallback; + } + return QColor::fromRgbF(std::clamp(array.at(0).toDouble(), 0.0, 1.0), + std::clamp(array.at(1).toDouble(), 0.0, 1.0), + std::clamp(array.at(2).toDouble(), 0.0, 1.0), + array.size() > 3 + ? std::clamp(array.at(3).toDouble(), 0.0, 1.0) + : 1.0); +} + +QJsonArray colorJson(const QColor &color) { + return {color.redF(), color.greenF(), color.blueF(), color.alphaF()}; +} + +void displayColor(QPushButton *button, const QColor &color) { + button->setProperty("materialColor", color); + button->setText(color.name(QColor::HexRgb).toUpper()); + button->setStyleSheet( + QStringLiteral("background-color: rgba(%1,%2,%3,%4); color: %5;") + .arg(color.red()) + .arg(color.green()) + .arg(color.blue()) + .arg(color.alpha()) + .arg(color.lightnessF() > 0.55 ? "#111111" : "#FFFFFF")); +} + +QDoubleSpinBox *scalarField(double minimum, double maximum, double step, + QWidget *parent) { + auto *field = new QDoubleSpinBox(parent); + field->setObjectName("materialScalarField"); + field->setRange(minimum, maximum); + field->setSingleStep(step); + field->setDecimals(3); + field->setKeyboardTracking(true); + return field; +} + +QString texturePath(const QJsonValue &value) { + if (value.isString()) { + return value.toString(); + } + if (value.isObject()) { + const QJsonObject object = value.toObject(); + return object.value("path").toString( + object.value("source").toString()); + } + return {}; +} + +QString resolvedTexturePath(const QString &baseDir, const QJsonValue &value) { + const QString path = texturePath(value); + if (path.isEmpty() || QFileInfo(path).isAbsolute()) { + return path; + } + return QDir(baseDir).absoluteFilePath(path); +} + +double channelAt(const QImage &image, double u, double v) { + if (image.isNull()) { + return 1.0; + } + const int x = std::clamp(static_cast(u * image.width()), 0, + image.width() - 1); + const int y = std::clamp(static_cast(v * image.height()), 0, + image.height() - 1); + return QColor::fromRgba(image.pixel(x, y)).lightnessF(); +} + +QColor imageAt(const QImage &image, double u, double v, + const QColor &fallback) { + if (image.isNull()) { + return fallback; + } + const int x = std::clamp(static_cast(u * image.width()), 0, + image.width() - 1); + const int y = std::clamp(static_cast(v * image.height()), 0, + image.height() - 1); + return QColor::fromRgba(image.pixel(x, y)); +} +} + +class MaterialPreviewWidget : public QWidget { + public: + explicit MaterialPreviewWidget(QWidget *parent = nullptr) + : QWidget(parent) { + setObjectName("materialPreview"); + setMinimumSize(250, 250); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + } + + void setMaterial(const QJsonObject &next, const QString &nextBaseDir) { + material = next; + baseDir = nextBaseDir; + albedoImage = QImage(resolvedTexturePath( + baseDir, material.value("albedoTexture"))); + normalImage = QImage(resolvedTexturePath( + baseDir, material.value("normalTexture"))); + metallicImage = QImage(resolvedTexturePath( + baseDir, material.value("metallicTexture"))); + roughnessImage = QImage(resolvedTexturePath( + baseDir, material.value("roughnessTexture"))); + aoImage = QImage( + resolvedTexturePath(baseDir, material.value("aoTexture"))); + update(); + } + + protected: + void paintEvent(QPaintEvent *) override { + const qreal scale = devicePixelRatioF(); + const int widthPixels = std::max(1, static_cast(width() * scale)); + const int heightPixels = + std::max(1, static_cast(height() * scale)); + QImage rendered(widthPixels, heightPixels, QImage::Format_ARGB32); + rendered.setDevicePixelRatio(scale); + + const QColor albedo = + jsonColor(material.value("albedo"), QColor::fromRgbF(.8, .8, .8)); + const QColor emission = jsonColor(material.value("emissiveColor"), + QColor::fromRgbF(0, 0, 0)); + const double metallic = + std::clamp(material.value("metallic").toDouble(0.0), 0.0, 1.0); + const double roughness = + std::clamp(material.value("roughness").toDouble(0.5), 0.02, 1.0); + const double ao = + std::clamp(material.value("ao").toDouble(1.0), 0.0, 1.0); + const double reflectivity = std::clamp( + material.value("reflectivity").toDouble(0.5), 0.0, 1.0); + const double emissionStrength = std::max( + 0.0, material.value("emissiveIntensity").toDouble(0.0)); + const double transmission = std::clamp( + material.value("transmittance").toDouble(0.0), 0.0, 1.0); + const double normalStrength = std::clamp( + material.value("normalMapStrength").toDouble(1.0), 0.0, 4.0); + const bool useNormal = + material.value("useNormalMap").toBool(true) && + !normalImage.isNull(); + const double cx = widthPixels * 0.5; + const double cy = heightPixels * 0.5; + const double radius = std::min(widthPixels, heightPixels) * 0.39; + const double lx = -0.42; + const double ly = -0.55; + const double lz = 0.72; + + for (int y = 0; y < heightPixels; ++y) { + QRgb *line = reinterpret_cast(rendered.scanLine(y)); + for (int x = 0; x < widthPixels; ++x) { + const int checker = ((x / 20) + (y / 20)) & 1; + const double background = checker ? 0.105 : 0.135; + const double px = (x - cx) / radius; + const double py = (cy - y) / radius; + const double rr = px * px + py * py; + if (rr > 1.0) { + const int c = static_cast(background * 255.0); + line[x] = qRgba(c, c, c, 255); + continue; + } + + double nx = px; + double ny = py; + double nz = std::sqrt(std::max(0.0, 1.0 - rr)); + double u = std::atan2(nx, nz) / + (2.0 * std::numbers::pi_v) + + 0.5; + double v = 0.5 - + std::asin(std::clamp(ny, -1.0, 1.0)) / + std::numbers::pi_v; + if (useNormal) { + const QColor sampled = + imageAt(normalImage, u, v, QColor(128, 128, 255)); + const double tx = sampled.redF() * 2.0 - 1.0; + const double ty = sampled.greenF() * 2.0 - 1.0; + nx += tx * normalStrength * 0.28; + ny += ty * normalStrength * 0.28; + const double length = std::sqrt(nx * nx + ny * ny + nz * nz); + nx /= length; + ny /= length; + nz /= length; + } + + const QColor sampledAlbedo = + imageAt(albedoImage, u, v, QColor(255, 255, 255)); + const double localMetallic = std::clamp( + metallic * channelAt(metallicImage, u, v), 0.0, 1.0); + const double localRoughness = std::clamp( + roughness * channelAt(roughnessImage, u, v), 0.02, 1.0); + const double localAo = + std::clamp(ao * channelAt(aoImage, u, v), 0.0, 1.0); + const double diffuse = std::max(0.0, nx * lx + ny * ly + nz * lz); + const double hx = lx; + const double hy = ly; + const double hz = lz + 1.0; + const double hlen = std::sqrt(hx * hx + hy * hy + hz * hz); + const double ndh = std::max( + 0.0, (nx * hx + ny * hy + nz * hz) / hlen); + const double exponent = 4.0 + + (1.0 - localRoughness) * + (1.0 - localRoughness) * 252.0; + const double specular = + std::pow(ndh, exponent) * + (0.12 + reflectivity * 0.88) * + (0.35 + localMetallic * 0.65); + const double fresnel = + std::pow(1.0 - std::clamp(nz, 0.0, 1.0), 5.0); + const double light = localAo * 0.17 + + diffuse * (0.83 - localMetallic * 0.38); + const double edgeTransmission = + transmission * (0.2 + fresnel * 0.55); + auto output = [&](double base, double texture, + double emitted) { + const double surface = base * texture * light + specular + + fresnel * reflectivity * 0.18; + return std::clamp(surface * (1.0 - edgeTransmission) + + background * edgeTransmission + + emitted * emissionStrength, + 0.0, 1.0); + }; + line[x] = qRgba( + static_cast(output(albedo.redF(), + sampledAlbedo.redF(), + emission.redF()) * + 255.0), + static_cast(output(albedo.greenF(), + sampledAlbedo.greenF(), + emission.greenF()) * + 255.0), + static_cast(output(albedo.blueF(), + sampledAlbedo.blueF(), + emission.blueF()) * + 255.0), + 255); + } + } + + QPainter painter(this); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + painter.drawImage(rect(), rendered); + } + + private: + QJsonObject material; + QString baseDir; + QImage albedoImage; + QImage normalImage; + QImage metallicImage; + QImage roughnessImage; + QImage aoImage; +}; + +MaterialEditorPanel::MaterialEditorPanel(QWidget *parent) : QWidget(parent) { + setObjectName("materialEditorPanel"); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + auto *header = new QWidget(this); + header->setObjectName("materialEditorHeader"); + auto *headerLayout = new QHBoxLayout(header); + headerLayout->setContentsMargins(10, 7, 10, 7); + titleLabel = new QLabel("Material Editor", header); + titleLabel->setObjectName("materialEditorTitle"); + statusLabel = new QLabel(header); + statusLabel->setObjectName("materialEditorStatus"); + auto *saveButton = new QPushButton("Save", header); + saveButton->setObjectName("materialSaveButton"); + headerLayout->addWidget(titleLabel, 1); + headerLayout->addWidget(statusLabel); + headerLayout->addWidget(saveButton); + layout->addWidget(header); + + auto *scroll = new QScrollArea(this); + scroll->setObjectName("materialEditorScroll"); + scroll->setWidgetResizable(true); + body = new QWidget(scroll); + body->setObjectName("materialEditorBody"); + bodyLayout = new QVBoxLayout(body); + bodyLayout->setContentsMargins(10, 10, 10, 12); + bodyLayout->setSpacing(9); + scroll->setWidget(body); + layout->addWidget(scroll, 1); + + saveTimer = new QTimer(this); + saveTimer->setSingleShot(true); + saveTimer->setInterval(260); + connect(saveTimer, &QTimer::timeout, this, + &MaterialEditorPanel::saveMaterial); + connect(saveButton, &QPushButton::clicked, this, + &MaterialEditorPanel::saveMaterial); + showEmptyState(); +} + +MaterialEditorPanel::~MaterialEditorPanel() { + if (saveTimer->isActive()) { + saveMaterial(); + } +} + +QJsonObject +MaterialEditorPanel::normalizedMaterial(const QJsonObject &source) const { + QJsonObject result = source; + if (!result.value("albedo").isArray()) + result.insert("albedo", QJsonArray{0.8, 0.8, 0.8, 1.0}); + if (!result.value("metallic").isDouble()) + result.insert("metallic", 0.0); + if (!result.value("roughness").isDouble()) + result.insert("roughness", 0.5); + if (!result.value("ao").isDouble()) + result.insert("ao", 1.0); + if (!result.value("reflectivity").isDouble()) + result.insert("reflectivity", 0.5); + if (!result.value("emissiveColor").isArray()) + result.insert("emissiveColor", QJsonArray{0.0, 0.0, 0.0, 1.0}); + if (!result.value("emissiveIntensity").isDouble()) + result.insert("emissiveIntensity", 0.0); + if (!result.value("normalMapStrength").isDouble()) + result.insert("normalMapStrength", 1.0); + if (!result.value("useNormalMap").isBool()) + result.insert("useNormalMap", true); + if (!result.value("transmittance").isDouble()) + result.insert("transmittance", 0.0); + if (!result.value("ior").isDouble()) + result.insert("ior", 1.45); + return result; +} + +void MaterialEditorPanel::openMaterial(const QString &path) { + if (saveTimer->isActive()) { + saveMaterial(); + } + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + QMessageBox::warning(this, "Material Editor", + "The material could not be opened."); + return; + } + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) { + QMessageBox::warning(this, "Material Editor", + "The material file is not valid JSON."); + return; + } + materialPath = QFileInfo(path).absoluteFilePath(); + const QJsonObject root = document.object(); + material = normalizedMaterial(root.value("material").isObject() + ? root.value("material").toObject() + : root); + showMaterial(); +} + +void MaterialEditorPanel::rebuildBody() { + while (QLayoutItem *item = bodyLayout->takeAt(0)) { + if (item->widget() != nullptr) + item->widget()->deleteLater(); + delete item; + } + preview = nullptr; + textureFields.clear(); + texturePreviews.clear(); +} + +void MaterialEditorPanel::showEmptyState() { + rebuildBody(); + titleLabel->setText("Material Editor"); + statusLabel->clear(); + auto *empty = new QLabel( + "Double-click a material in the Content Browser to edit it.", body); + empty->setObjectName("materialEditorEmpty"); + empty->setAlignment(Qt::AlignCenter); + empty->setWordWrap(true); + bodyLayout->addWidget(empty, 1); +} + +void MaterialEditorPanel::showMaterial() { + rebuildBody(); + loading = true; + titleLabel->setText(QFileInfo(materialPath).completeBaseName()); + statusLabel->setText("Ready"); + + preview = new MaterialPreviewWidget(body); + preview->setMaterial(material, QFileInfo(materialPath).absolutePath()); + bodyLayout->addWidget(preview); + + auto *surface = new QGroupBox("Surface", body); + auto *surfaceForm = new QFormLayout(surface); + albedoButton = new QPushButton(surface); + displayColor(albedoButton, + jsonColor(material.value("albedo"), QColor(204, 204, 204))); + metallicField = scalarField(0.0, 1.0, 0.01, surface); + roughnessField = scalarField(0.02, 1.0, 0.01, surface); + aoField = scalarField(0.0, 1.0, 0.01, surface); + reflectivityField = scalarField(0.0, 1.0, 0.01, surface); + metallicField->setValue(material.value("metallic").toDouble()); + roughnessField->setValue(material.value("roughness").toDouble()); + aoField->setValue(material.value("ao").toDouble()); + reflectivityField->setValue(material.value("reflectivity").toDouble()); + surfaceForm->addRow("Base Color", albedoButton); + surfaceForm->addRow("Metallic", metallicField); + surfaceForm->addRow("Roughness", roughnessField); + surfaceForm->addRow("Ambient Occlusion", aoField); + surfaceForm->addRow("Reflectivity", reflectivityField); + bodyLayout->addWidget(surface); + + auto *emission = new QGroupBox("Emission", body); + auto *emissionForm = new QFormLayout(emission); + emissiveButton = new QPushButton(emission); + displayColor(emissiveButton, + jsonColor(material.value("emissiveColor"), Qt::black)); + emissiveIntensityField = scalarField(0.0, 100.0, 0.1, emission); + emissiveIntensityField->setValue( + material.value("emissiveIntensity").toDouble()); + emissionForm->addRow("Color", emissiveButton); + emissionForm->addRow("Strength", emissiveIntensityField); + bodyLayout->addWidget(emission); + + auto *volume = new QGroupBox("Transmission", body); + auto *volumeForm = new QFormLayout(volume); + transmittanceField = scalarField(0.0, 1.0, 0.01, volume); + iorField = scalarField(1.0, 3.0, 0.01, volume); + transmittanceField->setValue( + material.value("transmittance").toDouble()); + iorField->setValue(material.value("ior").toDouble()); + volumeForm->addRow("Weight", transmittanceField); + volumeForm->addRow("IOR", iorField); + bodyLayout->addWidget(volume); + + auto *normal = new QGroupBox("Normal", body); + auto *normalForm = new QFormLayout(normal); + normalMapField = new QCheckBox(normal); + normalMapField->setChecked(material.value("useNormalMap").toBool()); + normalStrengthField = scalarField(0.0, 4.0, 0.05, normal); + normalStrengthField->setValue( + material.value("normalMapStrength").toDouble()); + normalForm->addRow("Use Normal Map", normalMapField); + normalForm->addRow("Strength", normalStrengthField); + bodyLayout->addWidget(normal); + + auto *textures = new QGroupBox("Texture Slots", body); + auto *textureLayout = new QVBoxLayout(textures); + const QList> slots{ + {"Base Color", "albedoTexture"}, {"Normal", "normalTexture"}, + {"Metallic", "metallicTexture"}, {"Roughness", "roughnessTexture"}, + {"Ambient Occlusion", "aoTexture"}, {"Opacity", "opacityTexture"}}; + for (const auto &[label, key] : slots) { + auto *row = new QWidget(textures); + row->setObjectName("materialTextureSlot"); + auto *rowLayout = new QHBoxLayout(row); + rowLayout->setContentsMargins(6, 5, 6, 5); + rowLayout->setSpacing(6); + auto *thumbnail = new QLabel(row); + thumbnail->setObjectName("materialTexturePreview"); + thumbnail->setFixedSize(38, 38); + auto *field = new QLineEdit(row); + field->setObjectName("materialTexturePath"); + field->setReadOnly(true); + field->setPlaceholderText("No image"); + auto *choose = new QToolButton(row); + choose->setText("Choose…"); + auto *clear = new QToolButton(row); + clear->setText("×"); + clear->setToolTip("Remove texture"); + auto *identity = new QWidget(row); + auto *identityLayout = new QVBoxLayout(identity); + identityLayout->setContentsMargins(0, 0, 0, 0); + identityLayout->setSpacing(2); + auto *name = new QLabel(label, identity); + name->setObjectName("materialTextureLabel"); + identityLayout->addWidget(name); + identityLayout->addWidget(field); + rowLayout->addWidget(thumbnail); + rowLayout->addWidget(identity, 1); + rowLayout->addWidget(choose); + rowLayout->addWidget(clear); + textureFields.insert(key, field); + texturePreviews.insert(key, thumbnail); + textureLayout->addWidget(row); + connect(choose, &QToolButton::clicked, this, + [this, key] { chooseTexture(key); }); + connect(clear, &QToolButton::clicked, this, + [this, key] { clearTexture(key); }); + updateTextureField(key); + } + bodyLayout->addWidget(textures); + bodyLayout->addStretch(); + + connect(albedoButton, &QPushButton::clicked, this, + [this] { setColor("albedo", albedoButton); }); + connect(emissiveButton, &QPushButton::clicked, this, + [this] { setColor("emissiveColor", emissiveButton); }); + const QList scalars{ + metallicField, roughnessField, aoField, + reflectivityField, emissiveIntensityField, + normalStrengthField, transmittanceField, + iorField}; + for (QDoubleSpinBox *field : scalars) { + connect(field, &QDoubleSpinBox::valueChanged, this, + [this](double) { materialChanged(); }); + } + connect(normalMapField, &QCheckBox::toggled, this, + [this](bool) { materialChanged(); }); + loading = false; +} + +void MaterialEditorPanel::setColor(const QString &key, QPushButton *button) { + const QColor initial = button->property("materialColor").value(); + const QColor color = QColorDialog::getColor( + initial, this, "Choose Material Color", QColorDialog::ShowAlphaChannel); + if (!color.isValid()) + return; + displayColor(button, color); + material.insert(key, colorJson(color)); + materialChanged(); +} + +void MaterialEditorPanel::chooseTexture(const QString &key) { + const QString selected = QFileDialog::getOpenFileName( + this, "Choose Texture", QFileInfo(materialPath).absolutePath(), + "Images (*.png *.jpg *.jpeg *.tga *.bmp *.hdr *.exr);;All Files (*)"); + if (selected.isEmpty()) + return; + const QDir materialDir(QFileInfo(materialPath).absolutePath()); + material.insert(key, materialDir.relativeFilePath(selected)); + updateTextureField(key); + materialChanged(); +} + +void MaterialEditorPanel::clearTexture(const QString &key) { + if (!material.contains(key)) + return; + material.remove(key); + updateTextureField(key); + materialChanged(); +} + +void MaterialEditorPanel::updateTextureField(const QString &key) { + QLineEdit *field = textureFields.value(key); + QLabel *thumbnail = texturePreviews.value(key); + if (field == nullptr || thumbnail == nullptr) + return; + const QString path = texturePath(material.value(key)); + field->setText(path); + const QImage image(resolvedTexturePath( + QFileInfo(materialPath).absolutePath(), material.value(key))); + if (image.isNull()) { + thumbnail->setPixmap(QPixmap()); + thumbnail->setText(path.isEmpty() ? "" : "!"); + } else { + thumbnail->clear(); + thumbnail->setPixmap(QPixmap::fromImage(image).scaled( + thumbnail->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); + } +} + +void MaterialEditorPanel::materialChanged() { + if (loading || materialPath.isEmpty()) + return; + material.insert("metallic", metallicField->value()); + material.insert("roughness", roughnessField->value()); + material.insert("ao", aoField->value()); + material.insert("reflectivity", reflectivityField->value()); + material.insert("emissiveIntensity", emissiveIntensityField->value()); + material.insert("normalMapStrength", normalStrengthField->value()); + material.insert("useNormalMap", normalMapField->isChecked()); + material.insert("transmittance", transmittanceField->value()); + material.insert("ior", iorField->value()); + preview->setMaterial(material, QFileInfo(materialPath).absolutePath()); + statusLabel->setText("Saving…"); + saveTimer->start(); +} + +void MaterialEditorPanel::saveMaterial() { + if (materialPath.isEmpty()) + return; + saveTimer->stop(); + QSaveFile file(materialPath); + if (!file.open(QIODevice::WriteOnly)) { + statusLabel->setText("Save failed"); + return; + } + QJsonObject root; + root.insert("material", material); + file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + if (!file.commit()) { + statusLabel->setText("Save failed"); + return; + } + statusLabel->setText("Saved"); + emit materialSaved(materialPath); +} diff --git a/editor/views/editor/viewportTools.cpp b/editor/views/editor/viewportTools.cpp index d3b4795b..22691462 100644 --- a/editor/views/editor/viewportTools.cpp +++ b/editor/views/editor/viewportTools.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -39,12 +40,20 @@ ViewportTools::ViewportTools(ViewportPanel *viewport, QWidget *parent) stopButton->setObjectName("viewportPlaybackButton"); stopButton->setIcon(style()->standardIcon(QStyle::SP_MediaStop)); stopButton->setToolTip("Stop and restore the scene"); + reloadButton = new QToolButton(toolbar); + reloadButton->setObjectName("viewportPlaybackButton"); + reloadButton->setIcon(style()->standardIcon(QStyle::SP_BrowserReload)); + reloadButton->setToolTip("Reload runtime"); + playButton->setShortcut(QKeySequence("Ctrl+P")); + pauseButton->setShortcut(QKeySequence("Ctrl+Shift+P")); + stepButton->setShortcut(QKeySequence("Ctrl+Alt+P")); tools->addStretch(); tools->addWidget(playButton); tools->addWidget(pauseButton); tools->addWidget(stepButton); tools->addWidget(stopButton); + tools->addWidget(reloadButton); tools->addSpacing(10); auto *transformGroup = new QActionGroup(toolbar); @@ -99,6 +108,8 @@ ViewportTools::ViewportTools(ViewportPanel *viewport, QWidget *parent) &ViewportPanel::stepRuntimeOnce); connect(stopButton, &QToolButton::clicked, viewport, &ViewportPanel::stopRuntimePlayback); + connect(reloadButton, &QToolButton::clicked, viewport, + &ViewportPanel::reloadRuntime); connect(transformGroup, &QActionGroup::triggered, this, [viewport](QAction *action) { viewport->setRuntimeControlMode(action->data().toInt()); @@ -126,4 +137,5 @@ void ViewportTools::updatePlaybackState(int state) { pauseButton->setEnabled(runtimeAvailable && state == 1); stepButton->setEnabled(runtimeAvailable && state != 0); stopButton->setEnabled(runtimeAvailable && state != 0); + reloadButton->setEnabled(runtimeAvailable); } diff --git a/editor/views/general/contentBrowser.cpp b/editor/views/general/contentBrowser.cpp index 82845c41..2504f6d0 100644 --- a/editor/views/general/contentBrowser.cpp +++ b/editor/views/general/contentBrowser.cpp @@ -174,6 +174,9 @@ ContentBrowserPanel::ContentBrowserPanel(const QString &projectFile, createMenu->addSeparator(); createMenu->addAction(style()->standardIcon(QStyle::SP_FileIcon), "Scene", this, &ContentBrowserPanel::createScene); + createMenu->addAction(style()->standardIcon(QStyle::SP_FileIcon), + "Material", this, + &ContentBrowserPanel::createMaterial); createMenu->addAction(style()->standardIcon(QStyle::SP_FileIcon), "TypeScript Script", this, &ContentBrowserPanel::createScript); @@ -302,6 +305,11 @@ void ContentBrowserPanel::openIndex(const QModelIndex &index) { navigateTo(info.absoluteFilePath()); return; } + const QString suffix = info.suffix().toLower(); + if (suffix == "amat" || suffix == "material") { + emit assetActivated(info.absoluteFilePath()); + return; + } QDesktopServices::openUrl(QUrl::fromLocalFile(info.absoluteFilePath())); } @@ -387,6 +395,31 @@ void ContentBrowserPanel::createScript() { } } +void ContentBrowserPanel::createMaterial() { + const QString path = uniquePath("New Material.amat"); + const QByteArray material = + "{\n" + " \"material\": {\n" + " \"albedo\": [0.8, 0.8, 0.8, 1.0],\n" + " \"metallic\": 0.0,\n" + " \"roughness\": 0.5,\n" + " \"ao\": 1.0,\n" + " \"reflectivity\": 0.5,\n" + " \"emissiveColor\": [0.0, 0.0, 0.0, 1.0],\n" + " \"emissiveIntensity\": 0.0,\n" + " \"normalMapStrength\": 1.0,\n" + " \"useNormalMap\": true,\n" + " \"transmittance\": 0.0,\n" + " \"ior\": 1.45\n" + " }\n" + "}\n"; + if (writeNewFile(path, material)) { + const QModelIndex index = model->index(path); + gridView->setCurrentIndex(index); + emit assetActivated(path); + } +} + void ContentBrowserPanel::renameSelection() { const QString path = selectedPath(); if (path.isEmpty()) { diff --git a/include/editor/views/fileExplorer.h b/include/editor/views/fileExplorer.h index 17953fcb..20032d34 100644 --- a/include/editor/views/fileExplorer.h +++ b/include/editor/views/fileExplorer.h @@ -32,6 +32,7 @@ class ContentBrowserPanel : public QWidget { signals: void selectionChanged(const QString &path); + void assetActivated(const QString &path); private: void navigateTo(const QString &path, bool recordHistory = true); @@ -41,6 +42,7 @@ class ContentBrowserPanel : public QWidget { void createFolder(); void createScene(); void createScript(); + void createMaterial(); void renameSelection(); void deleteSelection(); void revealSelection() const; diff --git a/include/editor/views/materialEditor.h b/include/editor/views/materialEditor.h new file mode 100644 index 00000000..8d998f81 --- /dev/null +++ b/include/editor/views/materialEditor.h @@ -0,0 +1,67 @@ +#ifndef ATLAS_MATERIALEDITOR_H +#define ATLAS_MATERIALEDITOR_H + +#include +#include +#include +#include + +class QCheckBox; +class QDoubleSpinBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QTimer; +class QVBoxLayout; +class MaterialPreviewWidget; + +class MaterialEditorPanel : public QWidget { + Q_OBJECT + + public: + explicit MaterialEditorPanel(QWidget *parent = nullptr); + ~MaterialEditorPanel() override; + + public slots: + void openMaterial(const QString &path); + + signals: + void materialSaved(const QString &path); + + private: + void showEmptyState(); + void showMaterial(); + void rebuildBody(); + void setColor(const QString &key, QPushButton *button); + void chooseTexture(const QString &key); + void clearTexture(const QString &key); + void updateTextureField(const QString &key); + void materialChanged(); + void saveMaterial(); + QJsonObject normalizedMaterial(const QJsonObject &source) const; + + QWidget *body = nullptr; + QVBoxLayout *bodyLayout = nullptr; + MaterialPreviewWidget *preview = nullptr; + QLabel *titleLabel = nullptr; + QLabel *statusLabel = nullptr; + QPushButton *albedoButton = nullptr; + QPushButton *emissiveButton = nullptr; + QDoubleSpinBox *metallicField = nullptr; + QDoubleSpinBox *roughnessField = nullptr; + QDoubleSpinBox *aoField = nullptr; + QDoubleSpinBox *reflectivityField = nullptr; + QDoubleSpinBox *emissiveIntensityField = nullptr; + QDoubleSpinBox *normalStrengthField = nullptr; + QDoubleSpinBox *transmittanceField = nullptr; + QDoubleSpinBox *iorField = nullptr; + QCheckBox *normalMapField = nullptr; + QHash textureFields; + QHash texturePreviews; + QTimer *saveTimer = nullptr; + QString materialPath; + QJsonObject material; + bool loading = false; +}; + +#endif diff --git a/include/editor/views/viewportTools.h b/include/editor/views/viewportTools.h index 96148138..453c9e32 100644 --- a/include/editor/views/viewportTools.h +++ b/include/editor/views/viewportTools.h @@ -21,6 +21,7 @@ class ViewportTools : public QWidget { QToolButton *pauseButton = nullptr; QToolButton *stepButton = nullptr; QToolButton *stopButton = nullptr; + QToolButton *reloadButton = nullptr; QLabel *fpsLabel = nullptr; bool runtimeAvailable = false; int playbackState = 0; From 1843c5f7343b9a28f1bd1f8fc91621e7ee069f85 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Thu, 16 Jul 2026 16:50:54 +0200 Subject: [PATCH 3/5] Fixed Qt material slot parsing --- editor/views/editor/materialEditor.cpp | 4 +- include/editor/core/themes.h | 86 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/editor/views/editor/materialEditor.cpp b/editor/views/editor/materialEditor.cpp index 96fbe15c..152745ea 100644 --- a/editor/views/editor/materialEditor.cpp +++ b/editor/views/editor/materialEditor.cpp @@ -474,11 +474,11 @@ void MaterialEditorPanel::showMaterial() { auto *textures = new QGroupBox("Texture Slots", body); auto *textureLayout = new QVBoxLayout(textures); - const QList> slots{ + const QList> materialSlots{ {"Base Color", "albedoTexture"}, {"Normal", "normalTexture"}, {"Metallic", "metallicTexture"}, {"Roughness", "roughnessTexture"}, {"Ambient Occlusion", "aoTexture"}, {"Opacity", "opacityTexture"}}; - for (const auto &[label, key] : slots) { + for (const auto &[label, key] : materialSlots) { auto *row = new QWidget(textures); row->setObjectName("materialTextureSlot"); auto *rowLayout = new QHBoxLayout(row); diff --git a/include/editor/core/themes.h b/include/editor/core/themes.h index 38461cb2..2f01ed50 100644 --- a/include/editor/core/themes.h +++ b/include/editor/core/themes.h @@ -164,6 +164,92 @@ inline constexpr const char* DARK_THEME = " image: none;\n" "}\n" "\n" +"#viewportToolbar {\n" +" background-color: #1B1E20;\n" +" border-bottom: 1px solid #353B3E;\n" +"}\n" +"\n" +"#viewportPlaybackButton,\n" +"#viewportModeButton,\n" +"#viewportOptionButton {\n" +" min-width: 26px;\n" +" min-height: 24px;\n" +" padding: 2px 7px;\n" +"}\n" +"\n" +"#viewportPlaybackButton {\n" +" border-radius: 12px;\n" +"}\n" +"\n" +"#viewportShadingMode {\n" +" min-width: 108px;\n" +" padding-top: 3px;\n" +" padding-bottom: 3px;\n" +"}\n" +"\n" +"#viewportFpsLabel {\n" +" color: #9DA8AE;\n" +" font-variant-numeric: tabular-nums;\n" +"}\n" +"\n" +"#materialEditorHeader {\n" +" background-color: #1B1E20;\n" +" border-bottom: 1px solid #363C3F;\n" +"}\n" +"\n" +"#materialEditorTitle {\n" +" color: #F3F6F7;\n" +" font-size: 15px;\n" +" font-weight: 650;\n" +"}\n" +"\n" +"#materialEditorStatus {\n" +" color: #89959B;\n" +"}\n" +"\n" +"#materialEditorScroll,\n" +"#materialEditorBody {\n" +" border: none;\n" +" background-color: #202324;\n" +"}\n" +"\n" +"#materialPreview {\n" +" background-color: #191C1D;\n" +" border: 1px solid #3A4144;\n" +" border-radius: 8px;\n" +"}\n" +"\n" +"#materialEditorEmpty {\n" +" color: #879299;\n" +" padding: 40px;\n" +"}\n" +"\n" +"#materialTextureSlot {\n" +" background-color: #1B1E20;\n" +" border: 1px solid #343B3E;\n" +" border-radius: 6px;\n" +"}\n" +"\n" +"#materialTexturePreview {\n" +" background-color: #25292B;\n" +" border: 1px solid #41494D;\n" +" border-radius: 4px;\n" +" color: #EF8C7B;\n" +" font-weight: 700;\n" +" qproperty-alignment: AlignCenter;\n" +"}\n" +"\n" +"#materialTextureLabel {\n" +" color: #CDD4D8;\n" +" font-weight: 600;\n" +"}\n" +"\n" +"#materialTexturePath {\n" +" color: #8F9AA0;\n" +" padding-top: 3px;\n" +" padding-bottom: 3px;\n" +"}\n" +"\n" "QLineEdit,\n" "QTextEdit,\n" "QPlainTextEdit,\n" From 5fcdeb7ebf584c846da782636c3dab192e3232b7 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Thu, 16 Jul 2026 16:55:46 +0200 Subject: [PATCH 4/5] Removed unsupported Qt stylesheet property --- editor/styling/dark.qss | 1 - include/editor/core/themes.h | 1 - 2 files changed, 2 deletions(-) diff --git a/editor/styling/dark.qss b/editor/styling/dark.qss index 43882ce8..6d6f40e0 100644 --- a/editor/styling/dark.qss +++ b/editor/styling/dark.qss @@ -182,7 +182,6 @@ QToolButton::menu-indicator { #viewportFpsLabel { color: #9DA8AE; - font-variant-numeric: tabular-nums; } #materialEditorHeader { diff --git a/include/editor/core/themes.h b/include/editor/core/themes.h index 2f01ed50..6b3e38bf 100644 --- a/include/editor/core/themes.h +++ b/include/editor/core/themes.h @@ -189,7 +189,6 @@ inline constexpr const char* DARK_THEME = "\n" "#viewportFpsLabel {\n" " color: #9DA8AE;\n" -" font-variant-numeric: tabular-nums;\n" "}\n" "\n" "#materialEditorHeader {\n" From acbf04beaf9c16efc1780d4ec9a3f93cdcc79ece Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Thu, 16 Jul 2026 17:01:09 +0200 Subject: [PATCH 5/5] Fixed material editing and assignment workflow --- editor/views/editor/editor.cpp | 17 +++++-- editor/views/editor/materialEditor.cpp | 65 ++++++++++++++++++++----- editor/views/editor/viewport.cpp | 14 +++++- editor/views/general/contentBrowser.cpp | 6 +-- include/atlas/runtime/context.h | 1 + include/editor/views/materialEditor.h | 7 ++- include/editor/views/viewport.h | 2 + runtime/lib/context.cpp | 27 ++++++++++ 8 files changed, 116 insertions(+), 23 deletions(-) diff --git a/editor/views/editor/editor.cpp b/editor/views/editor/editor.cpp index 14574efd..dba9a163 100644 --- a/editor/views/editor/editor.cpp +++ b/editor/views/editor/editor.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include "DockManager.h" #include "editor/debug.h" @@ -136,8 +137,8 @@ void EditorWindow::setupDocks() { .area = EditorDockArea::Bottom, .icon = style()->standardIcon(QStyle::SP_DirOpenIcon)}); - auto *materialEditor = new MaterialEditorPanel; - dockManager->addPanel( + auto *materialEditor = new MaterialEditorPanel(viewportPanel); + auto *materialDock = dockManager->addPanel( {.id = "materialEditor", .title = "Material Editor", .widget = materialEditor, @@ -154,13 +155,19 @@ void EditorWindow::setupDocks() { contentBrowser, &ContentBrowserPanel::clearSelection); connect(contentBrowser, &ContentBrowserPanel::selectionChanged, this, [this](const QString &path) { - if (!path.isEmpty()) { + const QString suffix = QFileInfo(path).suffix().toLower(); + if (!path.isEmpty() && suffix != "amat" && + suffix != "material") { viewportPanel->selectRuntimeObject(-1, false); } this->inspectorPanel->inspectFile(path); }); - connect(contentBrowser, &ContentBrowserPanel::assetActivated, - materialEditor, &MaterialEditorPanel::openMaterial); + connect(contentBrowser, &ContentBrowserPanel::assetActivated, this, + [materialEditor, materialDock](const QString &path) { + materialEditor->openMaterial(path); + materialDock->toggleView(true); + materialDock->raise(); + }); } void EditorWindow::saveLayout() { diff --git a/editor/views/editor/materialEditor.cpp b/editor/views/editor/materialEditor.cpp index 152745ea..d91a8ad7 100644 --- a/editor/views/editor/materialEditor.cpp +++ b/editor/views/editor/materialEditor.cpp @@ -1,5 +1,7 @@ #include +#include + #include #include #include @@ -93,6 +95,11 @@ QString resolvedTexturePath(const QString &baseDir, const QJsonValue &value) { return QDir(baseDir).absoluteFilePath(path); } +QImage loadTextureImage(const QString &baseDir, const QJsonValue &value) { + const QString path = resolvedTexturePath(baseDir, value); + return path.isEmpty() ? QImage() : QImage(path); +} + double channelAt(const QImage &image, double u, double v) { if (image.isNull()) { return 1.0; @@ -129,16 +136,15 @@ class MaterialPreviewWidget : public QWidget { void setMaterial(const QJsonObject &next, const QString &nextBaseDir) { material = next; baseDir = nextBaseDir; - albedoImage = QImage(resolvedTexturePath( - baseDir, material.value("albedoTexture"))); - normalImage = QImage(resolvedTexturePath( - baseDir, material.value("normalTexture"))); - metallicImage = QImage(resolvedTexturePath( - baseDir, material.value("metallicTexture"))); - roughnessImage = QImage(resolvedTexturePath( - baseDir, material.value("roughnessTexture"))); - aoImage = QImage( - resolvedTexturePath(baseDir, material.value("aoTexture"))); + albedoImage = + loadTextureImage(baseDir, material.value("albedoTexture")); + normalImage = + loadTextureImage(baseDir, material.value("normalTexture")); + metallicImage = + loadTextureImage(baseDir, material.value("metallicTexture")); + roughnessImage = + loadTextureImage(baseDir, material.value("roughnessTexture")); + aoImage = loadTextureImage(baseDir, material.value("aoTexture")); update(); } @@ -284,7 +290,9 @@ class MaterialPreviewWidget : public QWidget { QImage aoImage; }; -MaterialEditorPanel::MaterialEditorPanel(QWidget *parent) : QWidget(parent) { +MaterialEditorPanel::MaterialEditorPanel(ViewportPanel *viewport, + QWidget *parent) + : QWidget(parent), viewport(viewport) { setObjectName("materialEditorPanel"); auto *layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); @@ -300,8 +308,11 @@ MaterialEditorPanel::MaterialEditorPanel(QWidget *parent) : QWidget(parent) { statusLabel->setObjectName("materialEditorStatus"); auto *saveButton = new QPushButton("Save", header); saveButton->setObjectName("materialSaveButton"); + auto *assignButton = new QPushButton("Assign to Selected", header); + assignButton->setObjectName("materialAssignButton"); headerLayout->addWidget(titleLabel, 1); headerLayout->addWidget(statusLabel); + headerLayout->addWidget(assignButton); headerLayout->addWidget(saveButton); layout->addWidget(header); @@ -323,6 +334,8 @@ MaterialEditorPanel::MaterialEditorPanel(QWidget *parent) : QWidget(parent) { &MaterialEditorPanel::saveMaterial); connect(saveButton, &QPushButton::clicked, this, &MaterialEditorPanel::saveMaterial); + connect(assignButton, &QPushButton::clicked, this, + &MaterialEditorPanel::assignToSelectedObject); showEmptyState(); } @@ -378,6 +391,7 @@ void MaterialEditorPanel::openMaterial(const QString &path) { return; } materialPath = QFileInfo(path).absoluteFilePath(); + assignedObjectId = -1; const QJsonObject root = document.object(); material = normalizedMaterial(root.value("material").isObject() ? root.value("material").toObject() @@ -576,8 +590,8 @@ void MaterialEditorPanel::updateTextureField(const QString &key) { return; const QString path = texturePath(material.value(key)); field->setText(path); - const QImage image(resolvedTexturePath( - QFileInfo(materialPath).absolutePath(), material.value(key))); + const QImage image = loadTextureImage( + QFileInfo(materialPath).absolutePath(), material.value(key)); if (image.isNull()) { thumbnail->setPixmap(QPixmap()); thumbnail->setText(path.isEmpty() ? "" : "!"); @@ -622,5 +636,30 @@ void MaterialEditorPanel::saveMaterial() { return; } statusLabel->setText("Saved"); + if (assignedObjectId >= 0 && viewport != nullptr) { + viewport->applyRuntimeMaterial(assignedObjectId, materialPath); + } emit materialSaved(materialPath); } + +void MaterialEditorPanel::assignToSelectedObject() { + if (materialPath.isEmpty() || viewport == nullptr) { + return; + } + const int objectId = viewport->selectedRuntimeObjectId(); + if (objectId < 0) { + QMessageBox::information( + this, "Assign Material", + "Select a renderable object in the Hierarchy or Viewport first."); + return; + } + saveMaterial(); + if (!viewport->applyRuntimeMaterial(objectId, materialPath)) { + QMessageBox::warning( + this, "Assign Material", + "This material can only be assigned to a solid or model object."); + return; + } + assignedObjectId = objectId; + statusLabel->setText("Assigned · live updates enabled"); +} diff --git a/editor/views/editor/viewport.cpp b/editor/views/editor/viewport.cpp index 064aa091..29e4e52b 100644 --- a/editor/views/editor/viewport.cpp +++ b/editor/views/editor/viewport.cpp @@ -130,7 +130,6 @@ ViewportPanel::ViewportPanel(const QString &projectFile, QWidget *parent) [this] { shutdownRuntime(); }); } - winId(); } ViewportPanel::~ViewportPanel() { shutdownRuntime(); } @@ -488,6 +487,19 @@ bool ViewportPanel::saveRuntimeScene() { return runtimeContext != nullptr && runtimeContext->saveCurrentScene(); } +int ViewportPanel::selectedRuntimeObjectId() const { + return runtimeContext != nullptr ? runtimeContext->selectedObjectId() : -1; +} + +bool ViewportPanel::applyRuntimeMaterial(int id, const QString &path) { + if (runtimeContext == nullptr || id < 0 || path.isEmpty() || + !runtimeContext->setObjectMaterial(id, path.toStdString())) { + return false; + } + refreshSceneSnapshot(); + return true; +} + void ViewportPanel::playRuntime() { if (runtimeContext == nullptr) { return; diff --git a/editor/views/general/contentBrowser.cpp b/editor/views/general/contentBrowser.cpp index 2504f6d0..0d9c7f55 100644 --- a/editor/views/general/contentBrowser.cpp +++ b/editor/views/general/contentBrowser.cpp @@ -158,10 +158,10 @@ ContentBrowserPanel::ContentBrowserPanel(const QString &projectFile, gridView->setWrapping(true); gridView->setResizeMode(QListView::Adjust); gridView->setMovement(QListView::Static); - gridView->setGridSize(QSize(112, 104)); - gridView->setIconSize(QSize(56, 56)); + gridView->setGridSize(QSize(176, 142)); + gridView->setIconSize(QSize(64, 64)); gridView->setWordWrap(true); - gridView->setTextElideMode(Qt::ElideMiddle); + gridView->setTextElideMode(Qt::ElideNone); gridView->setSelectionMode(QAbstractItemView::ExtendedSelection); gridView->setContextMenuPolicy(Qt::CustomContextMenu); gridView->setUniformItemSizes(true); diff --git a/include/atlas/runtime/context.h b/include/atlas/runtime/context.h index d98cdc16..d9cfa7a1 100644 --- a/include/atlas/runtime/context.h +++ b/include/atlas/runtime/context.h @@ -121,6 +121,7 @@ class Context { bool setObjectProperty(int id, const std::string &component, int componentIndex, const std::string &propertyPath, const json &value); + bool setObjectMaterial(int id, const std::string &path); int addObjectComponent(int id, const json &component); bool setObjectParent(int childId, int parentId); bool deleteObject(int id); diff --git a/include/editor/views/materialEditor.h b/include/editor/views/materialEditor.h index 8d998f81..7e0ac8b9 100644 --- a/include/editor/views/materialEditor.h +++ b/include/editor/views/materialEditor.h @@ -14,12 +14,14 @@ class QPushButton; class QTimer; class QVBoxLayout; class MaterialPreviewWidget; +class ViewportPanel; class MaterialEditorPanel : public QWidget { Q_OBJECT public: - explicit MaterialEditorPanel(QWidget *parent = nullptr); + explicit MaterialEditorPanel(ViewportPanel *viewport, + QWidget *parent = nullptr); ~MaterialEditorPanel() override; public slots: @@ -38,6 +40,7 @@ class MaterialEditorPanel : public QWidget { void updateTextureField(const QString &key); void materialChanged(); void saveMaterial(); + void assignToSelectedObject(); QJsonObject normalizedMaterial(const QJsonObject &source) const; QWidget *body = nullptr; @@ -59,8 +62,10 @@ class MaterialEditorPanel : public QWidget { QHash textureFields; QHash texturePreviews; QTimer *saveTimer = nullptr; + ViewportPanel *viewport = nullptr; QString materialPath; QJsonObject material; + int assignedObjectId = -1; bool loading = false; }; diff --git a/include/editor/views/viewport.h b/include/editor/views/viewport.h index ceabc0c1..39b7f5ab 100644 --- a/include/editor/views/viewport.h +++ b/include/editor/views/viewport.h @@ -51,6 +51,8 @@ class ViewportPanel : public QWidget { bool deleteRuntimeObject(int id); int createRuntimeObject(const QString &type, const QString &name = {}); bool saveRuntimeScene(); + int selectedRuntimeObjectId() const; + bool applyRuntimeMaterial(int id, const QString &path); void playRuntime(); void pauseRuntime(); void stepRuntimeOnce(); diff --git a/runtime/lib/context.cpp b/runtime/lib/context.cpp index c3a90f32..d78838a8 100644 --- a/runtime/lib/context.cpp +++ b/runtime/lib/context.cpp @@ -1907,6 +1907,7 @@ void applyMaterial(GameObject &object, const MaterialDefinition &material) { if (auto *coreObject = dynamic_cast(&object); coreObject != nullptr) { coreObject->material = material.material; + coreObject->textures.clear(); for (const auto &texture : material.textures) { coreObject->attachTexture(texture); } @@ -1918,6 +1919,7 @@ void applyMaterial(GameObject &object, const MaterialDefinition &material) { for (auto &mesh : model->getObjects()) { if (mesh != nullptr) { mesh->material = material.material; + mesh->textures.clear(); } } for (const auto &texture : material.textures) { @@ -4375,6 +4377,31 @@ bool Context::setObjectProperty(int id, const std::string &component, return true; } +bool Context::setObjectMaterial(int id, const std::string &path) { + GameObject *object = findContextObject(*this, id); + if (object == nullptr || path.empty() || + (dynamic_cast(object) == nullptr && + dynamic_cast(object) == nullptr)) { + return false; + } + try { + applyMaterial(*object, loadMaterialDefinition(path, sceneDir)); + } catch (const std::exception &error) { + RUNTIME_LOG("Material could not be applied: " + + std::string(error.what())); + return false; + } + std::string storedPath = path; + std::error_code error; + const std::filesystem::path relative = + std::filesystem::relative(path, sceneDir, error); + if (!error && !relative.empty()) { + storedPath = relative.generic_string(); + } + editorObjectSourceData[id]["material"] = storedPath; + return true; +} + int Context::addObjectComponent(int id, const json &component) { GameObject *object = findContextObject(*this, id); if (object == nullptr || !component.is_object()) {