Initial commit
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to World Brush are documented here.
|
||||
|
||||
## 0.3.4 - 2026-08-14
|
||||
|
||||
- Added per-palette-entry Godot automatic mesh LOD controls.
|
||||
- Added one-click `Generate LODs` import configuration and reimport for only
|
||||
the active model source.
|
||||
- Added optional distance culling per model.
|
||||
- Added persistent Auto LOD controllers for painted instances.
|
||||
- Added a centralized, batched runtime distance manager.
|
||||
- Far instances now suspend rendering, shadows, GI, collision, areas, and child
|
||||
processing, then restore their original state when the camera returns.
|
||||
- Added `LOD Bias = 0` support for forcing the lightest generated LOD in tests.
|
||||
- Improved Arabic and English Auto LOD guidance.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
- Added independent manual near, medium, and distant LOD scenes per palette
|
||||
entry.
|
||||
- Added configurable transition and culling distances.
|
||||
- Added editor preview and runtime camera switching.
|
||||
|
||||
## 0.2.0
|
||||
|
||||
- Added scene palette persistence and independent per-scene brush profiles.
|
||||
- Added undo/redo for painting and erasing.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Initial 3D paint and erase workflow.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ALSAHLY
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,99 @@
|
||||
# World Brush
|
||||
|
||||
World Brush is a Godot 4 editor plugin for painting and erasing 3D scenes directly in the 3D viewport.
|
||||
|
||||
## Features
|
||||
|
||||
- Paint reusable 3D scenes directly in the editor viewport.
|
||||
- Erase previously painted instances.
|
||||
- Manage multiple scenes through a scene palette.
|
||||
- Keep an independent profile for every scene, including its medium/distant models and all LOD distances.
|
||||
- Enable Godot's automatic mesh LOD independently for any single active palette model.
|
||||
- Configure native LOD bias and an optional culling distance per model.
|
||||
- Apply `Generate LODs` and reimport only the active model source with one click.
|
||||
- Optionally assign medium and distant scenes to an active scene.
|
||||
- Keep only one LOD scene visible and switch it by camera distance at runtime.
|
||||
- Configure medium, distant, and optional culling distances per scene.
|
||||
- Adjust brush radius, instance count, and minimum spacing.
|
||||
- Randomize Y rotation and scale.
|
||||
- Rotate instances with the surface.
|
||||
- Limit placement by maximum slope.
|
||||
- Apply height offset along World Y or the surface normal.
|
||||
- Undo and redo painting and erasing actions.
|
||||
- Save palette and brush settings.
|
||||
- Arabic and English interface support.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Copy the `addons/world_brush` folder into your Godot project.
|
||||
2. Open the project in Godot.
|
||||
3. Go to `Project > Project Settings > Plugins`.
|
||||
4. Enable `World Brush`.
|
||||
|
||||
## Distance LOD workflow
|
||||
|
||||
### Automatic LOD from one model
|
||||
|
||||
1. Add and activate a model or scene in the scene palette.
|
||||
2. Enable `Godot Auto LOD — Single Model` for that palette entry.
|
||||
3. Adjust `Transition Detail` (`LOD Bias`) and the optional `Hide After` distance.
|
||||
4. Click `Apply & Reimport This Model` if the source needs to be regenerated.
|
||||
|
||||
The import action updates `Meshes > Generate LODs` only for the active model
|
||||
source (GLB, GLTF, FBX, OBJ, or Blend). It never changes every palette model.
|
||||
Every palette scene stores its own automatic LOD enabled state, bias, and cull
|
||||
distance. Turning the option off restores the original GeometryInstance3D
|
||||
values on painted instances managed by World Brush.
|
||||
Set LOD bias to `0` to force the lightest generated mesh for visual testing;
|
||||
use a higher value for normal gameplay quality.
|
||||
|
||||
World Brush stores a lightweight controller beside every painted Auto LOD
|
||||
instance. This keeps native LOD bias and culling active after saving, reopening,
|
||||
or running the edited scene without modifying the reusable source scene.
|
||||
At runtime, culled instances also disable their collision shapes, collision
|
||||
layers, area monitoring, shadows, global illumination, rendering, and child
|
||||
processing. They are restored slightly
|
||||
before the camera returns to the culling boundary to avoid visible or physical
|
||||
popping.
|
||||
A single runtime manager updates all painted instances in small batches. This
|
||||
avoids per-instance timers and keeps distance checks smooth with hundreds of
|
||||
props.
|
||||
|
||||
### Manual LOD from authored models
|
||||
|
||||
1. Add and activate the original high-detail scene in the scene palette.
|
||||
2. Enable `Add Distant Scene`.
|
||||
3. Assign the medium and distant versions of the same model.
|
||||
4. Set the distances where the medium and distant scenes begin.
|
||||
5. Optionally set `Hide After`; leave it at `0` to disable distance culling.
|
||||
|
||||
Newly painted instances show one LOD scene at a time. The editor preview follows
|
||||
the 3D editor camera while painting and navigating, and the saved runtime
|
||||
controller follows the active game camera. Older World Brush LOD groups are
|
||||
upgraded automatically when their scene is opened.
|
||||
|
||||
Switching the active palette scene restores only that scene's LOD profile.
|
||||
New scenes start with LOD disabled and empty medium/distant slots; settings are
|
||||
never inherited from the previously active scene. Changing a profile updates
|
||||
only painted instances whose original scene matches that profile.
|
||||
|
||||
## Compatibility
|
||||
|
||||
- Godot 4.6 or newer (tested with 4.6.3 and 4.7.1)
|
||||
- 3D projects
|
||||
- Editor plugin
|
||||
|
||||
## Support and known behavior
|
||||
|
||||
- Automatic mesh LOD depends on Godot's generated import LODs. Use `Apply &
|
||||
Reimport This Model` for imported GLB, GLTF, FBX, OBJ, or Blend sources.
|
||||
- Native LOD changes mesh detail; `Hide After` additionally disables rendering,
|
||||
shadows, GI, collision, area monitoring, and child processing at long range.
|
||||
- World Brush never changes the import settings of every palette entry at once.
|
||||
Each scene keeps an independent Auto LOD profile.
|
||||
- If an issue occurs, include the Godot version, renderer, reproduction steps,
|
||||
and the editor Output log in the report.
|
||||
|
||||
## License
|
||||
|
||||
World Brush is released under the MIT License.
|
||||
@@ -0,0 +1,7 @@
|
||||
[plugin]
|
||||
|
||||
name="World Brush"
|
||||
description="Paint, erase, scatter, and optimize reusable 3D scenes directly in the editor viewport."
|
||||
author="ALSAHLY"
|
||||
version="0.3.4"
|
||||
script="world_brush_plugin.gd"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://4uis0jk0b1bv
|
||||
@@ -0,0 +1,24 @@
|
||||
[gd_scene format=3 uid="uid://cxihuk2blp8s"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://4uis0jk0b1bv" path="res://addons/world_brush/ui/world_brush_dock.gd" id="1_u07ui"]
|
||||
|
||||
[node name="WorldBrushDock" type="VBoxContainer" unique_id=2039932584]
|
||||
layout_direction = 3
|
||||
offset_right = 280.0
|
||||
offset_bottom = 400.0
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
script = ExtResource("1_u07ui")
|
||||
|
||||
[node name="TitleLabel" type="Label" parent="." unique_id=1893496441]
|
||||
layout_mode = 2
|
||||
text = "فرشاة العالم"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="TopSeparator" type="HSeparator" parent="." unique_id=848279132]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="." unique_id=1926505656]
|
||||
layout_mode = 2
|
||||
text = "تمت استعادة المكتبة: 2 Scene(s)"
|
||||
horizontal_alignment = 2
|
||||
@@ -0,0 +1,207 @@
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
@export var auto_lod_enabled := true
|
||||
@export_range(0.0, 4.0, 0.05) var lod_bias := 1.0
|
||||
@export_range(0.0, 100000.0, 1.0) var cull_distance := 0.0
|
||||
@export var original_settings: Dictionary = {}
|
||||
|
||||
const REACTIVATION_RATIO := 0.9
|
||||
const RUNTIME_MANAGER_SCRIPT: Script = preload(
|
||||
"res://addons/world_brush/world_brush_auto_lod_manager.gd"
|
||||
)
|
||||
|
||||
var _runtime_states: Dictionary = {}
|
||||
var _runtime_far := false
|
||||
var _runtime_registered := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
call_deferred("_apply_settings")
|
||||
if not Engine.is_editor_hint():
|
||||
call_deferred("_register_with_runtime_manager")
|
||||
|
||||
|
||||
func configure(enabled: bool, detail_bias: float, hide_after: float) -> void:
|
||||
auto_lod_enabled = enabled
|
||||
lod_bias = clampf(detail_bias, 0.0, 4.0)
|
||||
cull_distance = maxf(hide_after, 0.0)
|
||||
_apply_settings()
|
||||
if not Engine.is_editor_hint() and is_inside_tree():
|
||||
_register_with_runtime_manager()
|
||||
|
||||
|
||||
func restore_original_settings() -> void:
|
||||
_set_runtime_far(false)
|
||||
var instance_root := get_parent()
|
||||
if instance_root == null:
|
||||
return
|
||||
for raw_path in original_settings:
|
||||
var geometry := instance_root.get_node_or_null(NodePath(str(raw_path))) as GeometryInstance3D
|
||||
if geometry == null:
|
||||
continue
|
||||
var values: Dictionary = original_settings[raw_path]
|
||||
geometry.lod_bias = float(values.get("lod_bias", 1.0))
|
||||
geometry.visibility_range_end = float(values.get("visibility_range_end", 0.0))
|
||||
|
||||
|
||||
func _register_with_runtime_manager() -> void:
|
||||
if Engine.is_editor_hint() or _runtime_registered or not is_inside_tree():
|
||||
return
|
||||
_capture_runtime_states()
|
||||
var manager := get_tree().get_first_node_in_group(
|
||||
&"world_brush_auto_lod_manager"
|
||||
)
|
||||
if manager == null:
|
||||
manager = Node.new()
|
||||
manager.name = "WorldBrushAutoLODManager"
|
||||
manager.set_script(RUNTIME_MANAGER_SCRIPT)
|
||||
var host := get_tree().current_scene
|
||||
if host == null:
|
||||
host = get_tree().root
|
||||
host.add_child(manager)
|
||||
if not manager.has_method(&"register_controller"):
|
||||
return
|
||||
manager.call(&"register_controller", self)
|
||||
_runtime_registered = true
|
||||
|
||||
|
||||
func _update_runtime_distance_state(camera: Camera3D = null) -> void:
|
||||
if not auto_lod_enabled or cull_distance <= 0.0:
|
||||
_set_runtime_far(false)
|
||||
return
|
||||
var instance_root := get_parent() as Node3D
|
||||
if camera == null:
|
||||
camera = get_viewport().get_camera_3d()
|
||||
if instance_root == null or camera == null:
|
||||
return
|
||||
var distance_squared := instance_root.global_position.distance_squared_to(
|
||||
camera.global_position
|
||||
)
|
||||
var threshold := cull_distance
|
||||
if _runtime_far:
|
||||
threshold *= REACTIVATION_RATIO
|
||||
_set_runtime_far(distance_squared > threshold * threshold)
|
||||
|
||||
|
||||
func _capture_runtime_states() -> void:
|
||||
if not _runtime_states.is_empty():
|
||||
return
|
||||
var instance_root := get_parent()
|
||||
if instance_root == null:
|
||||
return
|
||||
_capture_runtime_node(instance_root, instance_root)
|
||||
|
||||
|
||||
func _capture_runtime_node(instance_root: Node, node: Node) -> void:
|
||||
if node == self:
|
||||
return
|
||||
var path := str(instance_root.get_path_to(node))
|
||||
var values := {
|
||||
"process_mode": node.process_mode,
|
||||
}
|
||||
if node is CollisionObject3D:
|
||||
values["collision_layer"] = node.collision_layer
|
||||
values["collision_mask"] = node.collision_mask
|
||||
if node is CollisionShape3D:
|
||||
values["disabled"] = node.disabled
|
||||
if node is GeometryInstance3D:
|
||||
values["visible"] = node.visible
|
||||
values["cast_shadow"] = node.cast_shadow
|
||||
values["gi_mode"] = node.gi_mode
|
||||
if node is Area3D:
|
||||
values["monitoring"] = node.monitoring
|
||||
values["monitorable"] = node.monitorable
|
||||
_runtime_states[path] = values
|
||||
for child in node.get_children():
|
||||
_capture_runtime_node(instance_root, child)
|
||||
|
||||
|
||||
func _set_runtime_far(far: bool) -> void:
|
||||
if _runtime_far == far:
|
||||
return
|
||||
_runtime_far = far
|
||||
var instance_root := get_parent()
|
||||
if instance_root == null:
|
||||
return
|
||||
if _runtime_states.is_empty():
|
||||
_capture_runtime_states()
|
||||
for raw_path in _runtime_states:
|
||||
var node := instance_root.get_node_or_null(NodePath(str(raw_path)))
|
||||
if node == null or node == self:
|
||||
continue
|
||||
var values: Dictionary = _runtime_states[raw_path]
|
||||
# Keep the instance root alive so this controller can wake it again.
|
||||
if node != instance_root:
|
||||
node.process_mode = (
|
||||
Node.PROCESS_MODE_DISABLED
|
||||
if far
|
||||
else int(values.get("process_mode", Node.PROCESS_MODE_INHERIT))
|
||||
)
|
||||
if node is CollisionObject3D:
|
||||
node.collision_layer = 0 if far else int(values.get("collision_layer", 1))
|
||||
node.collision_mask = 0 if far else int(values.get("collision_mask", 1))
|
||||
if node is CollisionShape3D:
|
||||
node.set_deferred(
|
||||
"disabled",
|
||||
true if far else bool(values.get("disabled", false))
|
||||
)
|
||||
if node is GeometryInstance3D:
|
||||
node.visible = false if far else bool(values.get("visible", true))
|
||||
node.cast_shadow = (
|
||||
GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||
if far
|
||||
else int(values.get(
|
||||
"cast_shadow",
|
||||
GeometryInstance3D.SHADOW_CASTING_SETTING_ON
|
||||
))
|
||||
)
|
||||
node.gi_mode = (
|
||||
GeometryInstance3D.GI_MODE_DISABLED
|
||||
if far
|
||||
else int(values.get(
|
||||
"gi_mode",
|
||||
GeometryInstance3D.GI_MODE_STATIC
|
||||
))
|
||||
)
|
||||
if node is Area3D:
|
||||
node.set_deferred(
|
||||
"monitoring",
|
||||
false if far else bool(values.get("monitoring", true))
|
||||
)
|
||||
node.set_deferred(
|
||||
"monitorable",
|
||||
false if far else bool(values.get("monitorable", true))
|
||||
)
|
||||
|
||||
|
||||
func _apply_settings() -> void:
|
||||
if not auto_lod_enabled:
|
||||
restore_original_settings()
|
||||
return
|
||||
var instance_root := get_parent()
|
||||
if instance_root == null:
|
||||
return
|
||||
var geometry_instances: Array[GeometryInstance3D] = []
|
||||
_collect_geometry_instances(instance_root, geometry_instances)
|
||||
for geometry in geometry_instances:
|
||||
var geometry_path := str(instance_root.get_path_to(geometry))
|
||||
if not original_settings.has(geometry_path):
|
||||
original_settings[geometry_path] = {
|
||||
"lod_bias": geometry.lod_bias,
|
||||
"visibility_range_end": geometry.visibility_range_end,
|
||||
}
|
||||
geometry.lod_bias = lod_bias
|
||||
geometry.visibility_range_end = cull_distance
|
||||
|
||||
|
||||
func _collect_geometry_instances(
|
||||
node: Node,
|
||||
result: Array[GeometryInstance3D]
|
||||
) -> void:
|
||||
if node is GeometryInstance3D:
|
||||
result.append(node as GeometryInstance3D)
|
||||
for child in node.get_children():
|
||||
if child == self:
|
||||
continue
|
||||
_collect_geometry_instances(child, result)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cywjw8jfuoqxd
|
||||
@@ -0,0 +1,41 @@
|
||||
extends Node
|
||||
|
||||
const TARGET_REFRESH_FRAMES := 15
|
||||
const MAX_CHECKS_PER_FRAME := 64
|
||||
|
||||
var _controllers: Array[WeakRef] = []
|
||||
var _cursor := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
add_to_group(&"world_brush_auto_lod_manager")
|
||||
|
||||
|
||||
func register_controller(controller: Node) -> void:
|
||||
if controller == null:
|
||||
return
|
||||
_controllers.append(weakref(controller))
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _controllers.is_empty():
|
||||
return
|
||||
var camera := get_viewport().get_camera_3d()
|
||||
if camera == null:
|
||||
return
|
||||
var checks := clampi(
|
||||
ceili(float(_controllers.size()) / float(TARGET_REFRESH_FRAMES)),
|
||||
1,
|
||||
MAX_CHECKS_PER_FRAME
|
||||
)
|
||||
var completed := 0
|
||||
while completed < checks and not _controllers.is_empty():
|
||||
_cursor %= _controllers.size()
|
||||
var controller := _controllers[_cursor].get_ref() as Node
|
||||
if controller == null or not controller.is_inside_tree():
|
||||
_controllers.remove_at(_cursor)
|
||||
continue
|
||||
controller.call(&"_update_runtime_distance_state", camera)
|
||||
_cursor += 1
|
||||
completed += 1
|
||||
@@ -0,0 +1 @@
|
||||
uid://cdv8j33awshaq
|
||||
@@ -0,0 +1,124 @@
|
||||
@tool
|
||||
extends Node3D
|
||||
|
||||
|
||||
@export_range(1.0, 100000.0, 1.0, "or_greater")
|
||||
var medium_distance: float = 25.0
|
||||
|
||||
@export_range(2.0, 100000.0, 1.0, "or_greater")
|
||||
var distant_distance: float = 60.0
|
||||
|
||||
@export_range(0.0, 100000.0, 1.0, "or_greater")
|
||||
var cull_distance: float = 0.0
|
||||
|
||||
var _elapsed: float = 0.0
|
||||
var _active_level: int = -2
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if Engine.is_editor_hint():
|
||||
show_editor_preview()
|
||||
set_process(false)
|
||||
return
|
||||
|
||||
set_process(true)
|
||||
_update_from_camera()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_elapsed += delta
|
||||
if _elapsed < 0.15:
|
||||
return
|
||||
|
||||
_elapsed = 0.0
|
||||
_update_from_camera()
|
||||
|
||||
|
||||
func configure(
|
||||
new_medium_distance: float,
|
||||
new_distant_distance: float,
|
||||
new_cull_distance: float
|
||||
) -> void:
|
||||
medium_distance = maxf(new_medium_distance, 1.0)
|
||||
distant_distance = maxf(
|
||||
new_distant_distance,
|
||||
medium_distance + 1.0
|
||||
)
|
||||
cull_distance = maxf(new_cull_distance, 0.0)
|
||||
show_editor_preview()
|
||||
|
||||
|
||||
func show_editor_preview() -> void:
|
||||
_active_level = -2
|
||||
_set_active_level(0)
|
||||
|
||||
|
||||
func update_for_distance(distance: float) -> void:
|
||||
var near_level := get_node_or_null(^"Near") as Node3D
|
||||
var medium_level := get_node_or_null(^"Medium") as Node3D
|
||||
var distant_level := get_node_or_null(^"Distant") as Node3D
|
||||
var target_level := 0
|
||||
|
||||
if cull_distance > 0.0 and distance >= cull_distance:
|
||||
target_level = -1
|
||||
elif distant_level != null and distance >= distant_distance:
|
||||
target_level = 2
|
||||
elif medium_level != null and distance >= medium_distance:
|
||||
target_level = 1
|
||||
elif near_level == null:
|
||||
if medium_level != null:
|
||||
target_level = 1
|
||||
elif distant_level != null:
|
||||
target_level = 2
|
||||
else:
|
||||
target_level = -1
|
||||
|
||||
_set_active_level(target_level)
|
||||
|
||||
|
||||
func _update_from_camera() -> void:
|
||||
var viewport := get_viewport()
|
||||
if viewport == null:
|
||||
return
|
||||
|
||||
var camera := viewport.get_camera_3d()
|
||||
if camera == null:
|
||||
return
|
||||
|
||||
update_for_distance(
|
||||
camera.global_position.distance_to(global_position)
|
||||
)
|
||||
|
||||
|
||||
func _set_active_level(level: int) -> void:
|
||||
if bool(get_meta("harvest_hidden", false)):
|
||||
_active_level = -2
|
||||
_set_level_visible(^"Near", false)
|
||||
_set_level_visible(^"Medium", false)
|
||||
_set_level_visible(^"Distant", false)
|
||||
return
|
||||
|
||||
if level == _active_level:
|
||||
return
|
||||
|
||||
_active_level = level
|
||||
_set_level_visible(^"Near", level == 0)
|
||||
_set_level_visible(^"Medium", level == 1)
|
||||
_set_level_visible(^"Distant", level == 2)
|
||||
|
||||
|
||||
func set_harvest_hidden(hidden: bool) -> void:
|
||||
set_meta("harvest_hidden", hidden)
|
||||
_active_level = -2
|
||||
if hidden:
|
||||
_set_level_visible(^"Near", false)
|
||||
_set_level_visible(^"Medium", false)
|
||||
_set_level_visible(^"Distant", false)
|
||||
else:
|
||||
_update_from_camera()
|
||||
|
||||
|
||||
func _set_level_visible(path: NodePath, level_visible: bool) -> void:
|
||||
var level := get_node_or_null(path) as Node3D
|
||||
if level != null:
|
||||
level.visible = level_visible
|
||||
@@ -0,0 +1 @@
|
||||
uid://cnwnnkdv60i55
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://dkff06xnj76fl
|
||||
Reference in New Issue
Block a user