#Saurian Sorcery

4277 messages ยท Page 5 of 5 (latest)

obsidian wolf
#

i've never much liked trying to convey status effects through VFX/animations

obsidian wolf
#

@severe spruce
#questions message
my line drawing system draws a billboarded quad for line segments, and if there are two sequential segments, it also adds a bevel between them

obsidian wolf
#

assassin initial impl is done!
passive: after getting attacked, become untargetable for 1.5 seconds
attack: applies a poison which deals damage over time, stacks up to four times
spell: causes poison from attack to spread, this also stacks up to four times but is independent from the attack poison

#

i should say acid... would be hard for poison to do much to bots

obsidian wolf
main cosmos
#

Your new health bars

obsidian wolf
#

can't even notice that they're cylinders KEKW

#

outside of the rare specular reflection if you look in exactly the right direction like at the end

main cosmos
#

lol I think people might think it's a bug

#

I like them though, they look a lot better

obsidian wolf
#

i could just have any diffuse lighting and itd prob be more visually clear

#

will try that later

main cosmos
#

yeah the flat shading isn't as clear on cylindrical objects, especially when they represent a UI element (which gamers will expect to be a 2D sprite already)

#

idea: make particles fly off the health bar when things take damage

severe spruce
#

you could put rim lighting on it or something

obsidian wolf
#

ye making diffuse not black will

severe spruce
#

but i guess you maybe don't actually want them lit

obsidian wolf
#

it may be difficult

severe spruce
#

so just some dot(view, N) term to make it cylinderistic

main cosmos
#

cylinderisms

severe spruce
#

deccer in jaker's walls (cell)

main cosmos
#

pov: watching deccer communicate with me

obsidian wolf
#

i played dredge over the weekend

#

it is very neat. one cool thing i noticed is how it does foliage

#

its shading is entirely flat, so it does things like palms by coloring the underside of the leafs

#

for the most part the only thing that matters though is the silhoutte, i think only observing them from a distance helps make that work

#

other than that, i really like the scope of the game

#

also sidenote, obviously havent done much on this, insert triangle meme of work/life/hobby, pick two

severe spruce
#

the srgb correct triangle of life

obsidian wolf
main cosmos
#

I loved when they were stacked like

obsidian wolf
#

poor guys gonna get smushed

#

smooshed?

main cosmos
#

smooched

obsidian wolf
#

unfortunately i have to learn unreal

severe spruce
#

they be cute

obsidian wolf
#

i fixed a small bug with outlines

#

and it makes a lot of stuff look so much better, it was a pretty minor bug but when it's on outlines i guess it was more distracting than i realized

#

this is the old version, there are some non outlined pixels in there, like specifically the right corner it can be seen

#

vs now there's none of that

#

was kind of an interesting bug, i only write normal outline values if the g-buffer depth was less or equal to the traced water depth

#

but i early outed while tracing the water for the g-buffer depth, so actually the g-buffer depth was always a max for the traced depth

#

and for whatever reason that i do not understand, that would only cause artifacts on edges

obsidian wolf
#

also text rendering

obsidian wolf
severe spruce
#

das p cool

#

what is it?

obsidian wolf
#

starting a basic hud

#

i have two layers for this text rendering, one which is "reusable" that handles the color and shadow tags
the other one is on the game level, which expands tags like {tip=haste1} into a yellow color, and it also has the base text layer return a bounding box for that text so the input can query it if it's hovered

severe spruce
#

oh you wrote the formatting bit?

obsidian wolf
#

yeah it's just truetype

#

freetype

#

i mean

severe spruce
#

i mean the tags into formatting

obsidian wolf
#

yes the only thing i'm using external code for here is glyph rendering

#

the formatting is bespoke

severe spruce
#

v nice

obsidian wolf
#

hopefully the rest isn't too hellish NotLikeThis

#

i'm worried about needing hierarchies

#

since they feel pretty mandatory for anything moving, but it's annoying to do because needing to reference elements means i can't just mindlessly spam thinly abstracted draw calls

cursive chasm
#

Ay nice

obsidian wolf
#

up next: word wrapping, and making them look less like shit

cursive chasm
#

Thankfully word wrapping is not a cursed problem or anything haha

#

(Tbf if you do it only on word boundaries it's fine)

obsidian wolf
#

im prob just gonna wrap on full words and not allow words longer than the text width

#

yeah

cursive chasm
#

Yee

#

Also RTL stuff :(

obsidian wolf
#

i am engineering and design so dumb constraints are my jam

#

also no non english

#

if someone doesnt speak english and wants to play my game, they must put up a PR ๐Ÿ™‚

cursive chasm
#

Makes things much simpler indeed

obsidian wolf
#

the wavey text is not permanent in this state (hopefully obviously)

cursive chasm
#

That's one thing I find really dope, recursive tooltips

#

E.g. Crusader Kings III's

obsidian wolf
#

yeah i was planning on doing that, i didnt have it entirely figured out yet though

cursive chasm
#

You hover over your guy, tooltip tells you you have X of resource A, you hover over resource A on that tooltip, it tells you a bit about what that resource is, etc

#

Yeah

#

Never implemented it meself

obsidian wolf
#

it should be simple when i actually write code for it, but i just need to make sure that i implement a stack for active IDs

cursive chasm
#

Yup

main cosmos
#

If you wanted to do that

cursive chasm
#

Oh ye the tooltips are static

obsidian wolf
#

nah the tooltip is just below the text yeag

#

and stays active if the source text or the tooltip region are hovered

cursive chasm
#

It's an excellent solution imo

#

Cant believe it took this long to see games with it

obsidian wolf
#

i actually figured out a system i like for sourcing the tooltips, maybe its common, but i need to map something like "tip=haste1" to a color and a description

#

so the color and the description are just virtual methods on a base type, which i like from a code location perspective

#

i just confused myself

#

im doing something with enums that i'm too tired to remember

#

oh its the card instancing

cursive chasm
#

Why not make it data driven

obsidian wolf
#

the classes are reused, theyre the same classes as i use for the actual effects

#

i will figure out wtf i was thinking of tomorrow

obsidian wolf
#

okay, the actual thing that i was thinking of was how i implement the get_attack_title() and such

#

this is an issue i've had before, where you have to implement a lot of some type of thing and there can be a lot of unnecessary overhead, like huge switch statements, needing to register implementations, whatever else

#

implementing type-specific (not instance specific) functions like get_color or get_attack_title is one example of the best way to do that not being exactly clear to me. do you...

  • base class/derived class? this has the advantage of having compiler checking that your functions are implemented, but the disadvantage that if you have a value at runtime, converting that to the color isn't trivial, it'd be something like a switch statement that maps to an instance and you call it on the instance (this is what i'm doing with the hit effects that i mentioned above)
  • static class functions? slight advantage of not having to instance classes, but has the same downside as above
  • big switch blocks with an enum input? advantage of not having to instance classes, and you can convert a runtime-value into them trivially, but imo this has the downside that implementing new classes can be frustrating, because you have to find all the massive switch statements and plug your little thing in, you can't do separate files based on types
#

for the actual get_attack_title, what i'm doing that isn't any of those, is using an enum in a template parameter, then having a parent template that calls all of them, e.g.:

// lizard.hpp
enum LizardType {
    LizardType_Assassin,
    LizardType_Ranger,
};
template <LizardType T>
string get_name();

template <LizardType T>
string get_attack_title();
// assassin.hpp
template <>
string get_name<LizardType_Assassin>() {
    return "Assassin";
}

template <>
string get_attack_title<LizardType_Assassin>() {
    return AssassinAttack().get_name(); // by this pattern, this should be get_name<LizardType_Assassin, AbilityType_Attack>();
}```

// shop.hpp
template<LizardType T>
void add_lizard_card() {
CardGUI card(get_name<T>(), get_attack_title<T>(), get_spell_title<T>());
cards.push_back(card);
} ```

#

the reason that i like this is that if i add a new class, it feels easy to me, i just call add_lizard_card<LizardType_NewLizard>() wherever my shop generator is, and implement the functions. if i miss any functions, i get compiler errors, and there are only one place to add them anyways

#

i think this still has the problem of using runtime type values though

cursive chasm
#

I would just do this in data

obsidian wolf
#

as in load a csv?

cursive chasm
#

as in you have a big json file called "enemy" and you have a list of enemies

#

you don't need to make it fully data-driven, e.g. you could have a preset list of spells and such that they just refer to

#

instead of scripting or w/e

#

but you should consider making it data-driven to make your life easier tbh

obsidian wolf
#

thinking about this is for the intent of making my life easier, i'm not convinced it is though :P

cursive chasm
#

e.g. being able to reload these things at runtime, being able to add one very easily, not needing to rebuild every time you change something

#

also it scales easier

#

e.g. if you need to add a new field

obsidian wolf
#

what's the linkage from data to function

#

e.g. 1 sec

#

say i have 5 functions on 2 abilities (setup, targeting, start_casting, cast, finish_cast), and one more on the lizard

the lizard table probably has 3 entries, one for each ability, and the extra one, but what's actually in it? then i convert that to a function ptr at runtime?

obsidian wolf
#

this?...

#define REGISTER_FUNCTION(value, function) get_function_database().value[hash_view(#function)] = function;

struct FunctionDatabase {
    umap<uint64, AbilityTargetingFunction> ability_targeting_functions;
    umap<uint64, AbilitySetupFunction> ability_setup_functions;
    umap<uint64, AbilityStartCastFunction> ability_start_cast_functions;
    umap<uint64, AbilityCastFunction> ability_cast_functions;
    umap<uint64, AbilityEndCastFunction> ability_end_cast_functions;
    umap<uint64, LizardSetupFunction> lizard_setup_functions;
};
void register_functions() {
    REGISTER_FUNCTION(ability_setup_functions, assassin_attack_targeting_function);
    REGISTER_FUNCTION(ability_setup_functions, assassin_spell_targeting_function);
    REGISTER_FUNCTION(ability_targeting_functions, assassin_attack_targeting_function);
    REGISTER_FUNCTION(ability_targeting_functions, assassin_spell_targeting_function);
    REGISTER_FUNCTION(ability_start_cast_functions, assassin_attack_start_cast_function);
    REGISTER_FUNCTION(ability_start_cast_functions, assassin_spell_start_cast_function);
    REGISTER_FUNCTION(ability_cast_functions, assassin_attack_cast_function);
    REGISTER_FUNCTION(ability_cast_functions, assassin_spell_cast_function);
    REGISTER_FUNCTION(ability_end_cast_functions, assassin_attack_end_cast_function);
    REGISTER_FUNCTION(ability_end_cast_functions, assassin_spell_end_cast_function);
    REGISTER_FUNCTION(lizard_setup_functions, assassin_setup_function);
}```
cursive chasm
#

wtfrick

#

well I guess for a solo project this can work

obsidian wolf
#

this is worse than what i currently do, so what's the real way to do it

cursive chasm
#

usually for stuff like this you map them to data (enum or string or w/e)

#

and you just do it in a factory function or w/e

#

most things are data anyways when you think about it

#

e.g. between spell A and spell B

#

what is really the difference

#

some damage, some VFX, stuff like that

#

some stuff is quite different and that you can represent as enums

#

or structs you pass to a variant

#

and then you have a factory function that just fills this spell description

#

and spell descriptions are processed all the same

obsidian wolf
#

loading data would probably work well for some aspects of it like names, but in the past i have not liked the more functional part of it

cursive chasm
#

I mean, you're the only person on the project so it really doesn't matter

#

unless you want to support mods

#

if you do then this is the only way, but if you don't you don't need to care, carry on the way you're doin it

obsidian wolf
#

i still don't think i'll ever release this, but i do want to learn from it so figuring out a way that i'm happy with is worthwhile to me

cursive chasm
#

right

#

well in a game with many people, you can't expect the guy who's setting up data to know code

#

so you have to make things easy for them too and that means data driving

#

in a game with few people you might do both the data and the code, in which case you can go with your approach, but it doesn't handle mods, scaling the team, stuff like that

obsidian wolf
#

see if abilities were scripts this would be really easy :)

cursive chasm
#

and overall it's much dirtier, if you think about it you're embedding data into your code like magic values basically

#

yeah for sure

#

that's the next step I guess, but even then you might not want to make everything scriptable

obsidian wolf
#

surely there's some way to macro magic this to store it next to the function

#

instead of in the register_functions thing, have the register be next to the function definition

cursive chasm
#

in AAA you usually have a data definition format that automagically generates stuff, and lets you define functions which you can then call from data

obsidian wolf
#

yeah

cursive chasm
#

that's a whole thing tho lol

obsidian wolf
#

i've worked with those before, it is nice, but yeah

cursive chasm
#

ah nice

obsidian wolf
#

ideally for me, to make a new lizard, i add an entry to the enum, then just add a single file and implement the functions in there

cursive chasm
#

sry didn't mean to AAAxplain KEKW

obsidian wolf
#

i only interact with those 2 files, and barely even with the first one

cursive chasm
#

hmm right

obsidian wolf
#

so i can do

template<>
void targeting<LizardType_Assassin, AbilityFunctionType_Targeting>() {
    Caster& caster_comp = scene->registry.get<Caster>(caster);

    if (taunted(*this, caster_comp))
        return;
    
    plus_targeting(1, *this, entry_gather_function, entry_eval_function);
}
REGISTER_FUNCTION(ability_targeting_functions, targeting<LizardType_Assassin, AbilityFunctionType_Targeting>)

and i don't need any header that way

cursive chasm
#

ye I see

#

but then you need to map the enum to its template instantiation right

obsidian wolf
#

isn't there a macro way to make code run at startup ๐Ÿ™ˆ

#

like constructor of a new type or some balogna

#

is type just based off FILE/LINE, so like

#
#define HORROR(code)
struct HORROR_##__FILE__##__LINE__ { HORROR_##__FILE__##__LINE__() { code }
HORROR_##__FILE__##__LINE__ horror_instance;```
#

@severe spruce weren't you doing something like this? with the macros

#

okay

#

i will try this

severe spruce
#

ye doable

#

static init order fiasco in 3...2...

obsidian wolf
#

i don't think there's an issue, cause i'm doing

    static Database database;
    return database;
}```
#

first static init will instance the destination first

severe spruce
#

Meyers singleton my beloved

obsidian wolf
#

going 2 make so many people on stack overflow mad wit this one

obsidian wolf
#

one slightly unfortunate bit about this is that i don't get compiler feedback if a function isn't implemented and i expect it to be

#

but eh, that's minor

obsidian wolf
#

never seen this one before

#

i believe it is a CLion skill issue

obsidian wolf
#

here's another issue about this

#

it's minor but in a high traffic system

#
template<>
void targeting<LizardType_Assassin, AbilityFunctionType_Targeting>() {
    Caster& caster_comp = scene->registry.get<Caster>(caster);

    if (taunted(*this, caster_comp))
        return;
    
    plus_targeting(1, *this, entry_gather_function, entry_eval_function);
}
REGISTER_FUNCTION(ability_targeting_functions, targeting<LizardType_Assassin, AbilityFunctionType_Targeting>)```
let's say i really do go with this
#

there's no base object, and a lot of abilities have state

#

so i probably have to pass state into it, which means almost every ability function has to deal with void* shenanigans

#

e.g. i have to deal with this

struct AssassinAttackPayload {
    Scene* scene;
    entt::entity caster;
    int32 phase = 0;
};
template<>
void* setup<LizardType_Assassin, AbilityType_Attack>(Scene* scene, entt::entity caster) {
    Health& health = scene->registry.get<Health>(caster);
    entt::sink sink{health.damage_signal};
    sink.connect<handle_assassin_damaged>();

    return new AssassinAttackPayload{scene, caster, 0};
}
REGISTER_ABILITY_SETUP(LizardType_Assassin, AbilityType_Attack);

template<>
void cleanup<LizardType_Assassin, AbilityType_Attack>(void* data) {
    AssassinAttackPayload& payload = *((AssassinAttackPayload*)data;
    // ...
}
#

so i'm wondering if there's any better way to handle this

obsidian wolf
#

...lambdas

obsidian wolf
#

hmmm

#

what if i did actually make overriding the ability class optional

#

or even mandatory

#

actually making the ability class templated makes the most sense

#

definitely would be if i could add member variables at least

#

okay i'm keeping the inheritence based ability system, and just moving parameters to data

#

@cursive chasm i'm curious, if i have a table of abilities, and each ability has different parameters, do you have any suggestion for how to store them?
my two ideas are

  1. just have a list of slots that the ability uses in documented ways, e.g. slot1 is damage, slot2 is range, etc.
  2. alternatively, do something similar, but just alternate fields in each row so it's something like "damage, 80, range, 3, duration, 1.2", then the ability can reference those by name, and the table parsing handles unnamed columns as those special fielsd
cursive chasm
#

Hmm yeah you could do it either way really, I'd recommend doing it in a structured, readable textual format though, e.g. json

#

First approach has the drawback that you may change the slots' semantics, which json covers (parameters are named)
The second approach also solves this but has the problem of being a bit less readable and very brittle

#

First approach also has the drawback of being unreadable

obsidian wolf
#

i am satisfied

#

example for one character:

// lizards
        {
            "Type": "LizardType_Assassin",
            "Name": "Empyrea",
            "Model": "resources/models/empyrea/empyrea.sbjmod",
            "Scale": 0.6,
            "HurtEmitter": "",
            "Color": "95bb25"
        },

// abilities
        {
            "Lizard": "LizardType_Assassin",
            "Type": "AbilityType_Attack",
            "Pre": 0.5,
            "Post": 0.5,
            "CD": 0.1,
            "Damage": 2.0,
            "Duration": 1.5,
            "Name": "Acid Torrent",
            "Description": "{tip=Reaction}Reaction{\\tip}(Damage): Gain {tip=Ethereal}Ethereal{\\tip}({ref=Duration}s)\n{tip=Damage}{ref=Damage} Damage{\\tip}. Increase {tip=Acid}Acid{\\tip} on target (max 3)."
        },
        {
            "Lizard": "LizardType_Assassin",
            "Type": "AbilityType_Spell",
            "Pre": 0.4,
            "Post": 0.5,
            "Duration": 6.0,
            "UpdateDelay": 0.5,
            "Name": "Catalysis",
            "Description": "All {tip=Acid}Acid{\\tip} ticks gain {tip=Slow}Slow 1{\\tip}, {tip=Splash}Splash 2{\\tip}, and {ref=Attack} can increase up to {tip=Acid}Acid 4{\\tip} (6s)."
        },

// effects
    "Acid": {
        "Level1": 1.0,
        "Level2": 2.0,
        "Level3": 4.0,
        "Level4": 8.0,
        "Description": "Acid 1: Deals {ref=Level1} damage every second\nAcid 2: Deals {ref=Level2} damage every second\nAcid 3: Deals {ref=Level3} damage every second\nAcid 4: Deals {ref=Level4} damage every second",
        "Color": "8fd411"
    },```
cursive chasm
#

Wow, excellent

obsidian wolf
#

one very minor issue that i have which explaining might help

#

i have a {lift=n} option in my base text layer, which just vertically shifts text, i planned on using it for tooltips to just raise then a couple of pixels for responsiveness's sake

#

but tooltips are id based, and the gui manager only keeps track of which region and id is hovered

#

ex: "{tip=HoverMe}Hover this!{\tip}, or you could also hover {tip=HoverMe}this{\tip} and it'll show the same tooltip in a different spot"

#

both solutions i have for adding state to the hovered state, region or string index, both feel fragile

#

i will probably do the string index because it's slightly less fragile (changing the string is guaranteed to change the region, but slightly moving the text doesn't change the string index)

#

it's somewhat odd though because i have to make sure to use the source string, because i'm doing it based off inserting strings

obsidian wolf
#

man

#

i've had a growing problem of dealing with materials with a GUI system

#

i've learned a bunch about how i would want to design the interface for a renderer from this project, even having written multiple renderers before

#

like i was primarily writing my render function for my render scene to operate on one list of renderables, and then renderables have some state that would do things like skip themselves in a shadow pass

#

but i think it's probably not a smart idea to avoid multiple renderable lists

#

i currently have lists for normal renderables, rigged renderables, static renderables, and UI renderables

#

which means a lot of the code that iterates over renderable lists has bloated and duplicated

#

material and textures are also just weird

#

i think i thought of materials as assets, which is a mistake imo on the renderer level

#

there are too many cases where you are working on a system that needs a material and you just want to quickly create it

#

that's the main time where the interface to your material system matters--the case of actually loading a material asset from a disk is very unique and you're going to be able to make that work no matter what

#

for a concrete example, i am working on GUI objects, e.g. "Text" "Panel" "List Box", etc.

#

a panel is going to be something like


struct Panel : ItemGUI {
    v2i padding;
    unique_ptr<ItemGUI> item;
    FilePath texture;
    void draw() override;
    void update() override;
    range2i get_occupied_region() override;
};

void Panel::draw() {
    Renderable r = {};
    r.frame_allocated = true;

    // background
    r.mesh_id = upload_mesh(generate_rounded_quad(given_region, settings), true);
    r.material_id = textured_mat;
    r.sort_index = depth;
    render_scene.ui_renderables.push_back(r);

    // full outline
    r.mesh_id = upload_mesh(generate_rounded_outline(given_region, settings), true);
    r.material_id = untextured_mat;
    r.sort_index = depth + 5;
    render_scene.ui_renderables.push_back(r);

    if (!item) return;
    item->draw();
}```
#

then how do the materials work? is the panel in change of uploading the pipelines? are there some preset pipelines? do the preset pipelines have default materials? because untextured_mat is very widely used, but textured_mat is going to be specific to this panel

obsidian wolf
#

answer i will probably go with because it's easy is just functions to construct the types of materials i need, a lot of calls to them, and caching in those functions

#

on a briefer, less rubber-ducky rant note, i hope this abstraction works ๐Ÿคž (testing tomorrow)

        Panel panel(*this);
        panel.padding = {15, 15};
        panel.given_region = {tooltip_pos, tooltip_pos + v2i{400, 800}};
        TextGUI text(*this);
        text.text = effect_entry.description;
        text.ref_floats = &effect_entry.extra_floats;
        text.ref_strings = &effect_entry.extra_strings;
        panel.item = make_unique<TextGUI>(text);
        gui_manager->add_root_item(make_unique<Panel>(panel), 600);
#

before unknown

obsidian wolf
#

this is what debugging feels like to me 90% of the time

obsidian wolf
#

need to get this readable and slightly better designed but i'm happy to have something now

obsidian wolf
#

oddly my original prototype design is really hard to implement

#

because the background aligning with the vertical divisions is not super natural with just a parent hierarchy

#

i'm probably just gonna do something placeholder now that i think about it, cause i don't really like the design enough to muddle up the abstraction yet

obsidian wolf
obsidian wolf
#

debating if i'm gonna do padding on the atlas so i can get away with linear filtering

#

i probably should, the quality increase is much more rapid than increasing resolution of the actual text

main cosmos
#

you could do manual linear filtering froge_bleak

obsidian wolf
#

i dont think i will ๐Ÿ™‚

#

and this is acceptable quality for me

obsidian wolf
#

the math doesn't quite add up here but this is what the 3d text is for

obsidian wolf
#

back to it, thinking about adding variations to each lizard to save me some time

#

i could really leverage the keywords i'm working on if i do this, and i dont have much for reasons not to do it, so its prob almost guarnateed

#

some examples, i would prob just do color variations for each

#

this is probably unreadable without tooltips

#

idea should be clear though

obsidian wolf
#

some updates

#

i am lowering the quality of some of the models

#

with the outline shader, and the scale of lizards, smaller details dont read very well, so simplifying is necessary

#

also removing baked textures, because it's too much work and if i want to do simple variants, it'll be even harder (uv map swap vs fully separate bakes)

#

here's an example, he looks worse in blender imo (new on right), but i think it'll be a slight improvement in game

#

also thickened his bow slightly for the same reasons

#

UV swap example (this literally takes like 30 seconds per color swap)

obsidian wolf
cursive chasm
obsidian wolf
#

i am them

obsidian wolf
#

i should really optimize my builds one of these days

#

i've seen a lot of complaints about the effect of magic_enum on compile times

#

i'm using it very heavily which simulatenously means it could be having a real impact, but also it would be a ton of effort to take out

obsidian wolf
#

i need to update my enemy ik to suck less

#

but its been like 6 months

#

and this code is entirely undocumented, and 200 lines of magical math with mediocre variable names DESPAIR

obsidian wolf
#

i really like how my hit, hit effect, and hit reaction system is turning out

#

once i implement more guys (im at 2 right now), i will write about it

obsidian wolf
#

got inkbound yesterday, that game is really good, its like made for me

#

i do like realtime games but thats not a requirement for me enjoying it

#

i will probably be pulling some inspo from it :^)

obsidian wolf
#

sneak peek cause making these takes a very long time

main cosmos
#

comfy

obsidian wolf
#

i dont love the way it looks right now tbh but im hoping to fix it with some shader magic and decoration

#

way down the line

#

i'm happy with the level of variation from the desert tho (attached blender screenshot for ref)

urban lantern
#

merci โค๏ธ