DinkydauSet / ExploreFractals

A tool for testing the effect of Mandelbrot set Julia morphings
GNU General Public License v3.0
5 stars 1 forks source link

Simplify adding a new procedure #12

Closed DinkydauSet closed 3 years ago

DinkydauSet commented 3 years ago

By a procedure I mean what is usually a fractal type. In general it's a procedure that turns a pixel coordinate in an iteration count, which could be abused to render all kinds of stuff. It doesn't have to be a fractal.

Some properties of procedures are stored in multiple locations, for example whether they can use guessing. That property is inserted in the templates here:

/* createNewRenderTemplated has template:
    template <int formula_identifier, bool guessing, bool use_avx, bool julia>
*/
void FractalCanvas::createNewRender(bool headless) {
    if(debug) {
        int formulaID = S.get_formula_identifier();
        assert(formulaID == S.get_formula().identifier);
        cout << "creating new render with formula: " << formulaID << " (" << S.get_formula().name << ")" << endl;
    }
    switch (S.get_formula_identifier()) {
        case PROCEDURE_M2: {
            if (using_avx) {
                if (S.get_julia()) {
                    createNewRenderTemplated<PROCEDURE_M2, true, true, true>(headless);
                }
                else {
                    createNewRenderTemplated<PROCEDURE_M2, true, true, false>(headless);
                }
            }
            else {
                if (S.get_julia()) {
                    createNewRenderTemplated<PROCEDURE_M2, true, false, true>(headless);
                }
                else {
                    createNewRenderTemplated<PROCEDURE_M2, true, false, false>(headless);
                }
            }
            break;
        }
        case PROCEDURE_BURNING_SHIP : {
            if (S.get_julia()) {
                createNewRenderTemplated<PROCEDURE_BURNING_SHIP, false, false, true>(headless);
            }
            else {
                createNewRenderTemplated<PROCEDURE_BURNING_SHIP, false, false, false>(headless);
            }
            break;
        }
        case PROCEDURE_RECURSIVE_FRACTAL: {
            createNewRenderTemplated<PROCEDURE_RECURSIVE_FRACTAL, true, false, false>(headless);
            break;
        }
        case PROCEDURE_CHECKERS : {
            createNewRenderTemplated<PROCEDURE_CHECKERS, true, false, false>(headless);
            break;
        }
        procedureRenderCase(PROCEDURE_M3)
        procedureRenderCase(PROCEDURE_M4)
        procedureRenderCase(PROCEDURE_M5)
        procedureRenderCase(PROCEDURE_HIGH_POWER)
        procedureRenderCase(PROCEDURE_TRIPLE_MATCHMAKER)
        case PROCEDURE_BI: {
            createNewRenderTemplated<PROCEDURE_BI, true, false, false>(headless);
        }

and also in the definition of the Formula objects:

//identifier; isGuessable; inflectionPower, isEscapeTime, escapeRadius, name
const Formula M2 = { PROCEDURE_M2, true, 2, true, 4, "Mandelbrot power 2" };
const Formula M3 = { PROCEDURE_M3, true, 3, true, 2, "Mandelbrot power 3" };
const Formula M4 = { PROCEDURE_M4, true, 4, true, pow(2, 2 / 3.), "Mandelbrot power 4" }; //Escape radius for Mandelbrot power n: pow(2, 2/(n-1))
const Formula M5 = { PROCEDURE_M5, true, 5, true, pow(2, 2 / 4.), "Mandelbrot power 5" };
const Formula BURNING_SHIP = { PROCEDURE_BURNING_SHIP, false, 2, true, 4, "Burning ship" };
const Formula CHECKERS = { PROCEDURE_CHECKERS, true, 2, false, 4, "Checkers" };
const Formula TRIPLE_MATCHMAKER = { PROCEDURE_TRIPLE_MATCHMAKER, true, 2, false, 550, "Triple Matchmaker" };
const Formula HIGH_POWER = { PROCEDURE_HIGH_POWER, true, 2, true, 4, "High power Mandelbrot" };

Maybe I should get rid of Formula objects altogether. I thought it would be useful to have one object with properties for each formula/procedure in the program. The templates ruin that idea. Also I have a hardcoded escape radius of 4 for Mandelbrot power 2 in the Render class:

while (zrsqr + zisqr <= 4.0 && iterationCount < maxIters) {
    zi = zr * zi;
    zi += zi;
    zi += ci;
    zr = zrsqr - zisqr + cr;
    zrsqr = zr * zr;
    zisqr = zi * zi;
    iterationCount++;
}

and for some formulas the escape radius is not applicable, so the construction with Formula objects isn't as nice as I hoped.

DinkydauSet commented 3 years ago

What is currently required to add a procedure:

  1. add a const int in common.cpp to use as an identifier
  2. add a const Formula in common.cpp, using the identifier
  3. add the identifier to the getFormulaObject function in common.cpp
  4. add the possible cases for the identifier in FractalCanvas::createNewRender (currently in ExploreFractals.cpp)
  5. add the calculation to calcPoint in Render.cpp

Then, in order to actually use the procedure, a menu option has to be added. It's also possible to edit the JSON and change the identifier there to change the procedure.

DinkydauSet commented 3 years ago

I now understand what the problem is with those templates. I declare my formulas as "const" and I expected to be able to use those values as template parameters, and then found out that I can't. The problem is a misunderstanding of what const means.

Const only means the value is initialized once and then never changed. It doesn't mean the value is known at compile time.

What I needed is a constexpr. A constexpr is known at compile time and can be used as a template parameter.

Unfortunately std::string can't be a constexpr, which is about to change in c++20 but my visual studio and the latest gcc don't have that feature yet so I can't declare a Formula as constexpr just because there's a string in there.

This person on stackoverflow has a solution: https://stackoverflow.com/a/37876799 . A small test program:

#include <string>

//from https://stackoverflow.com/a/37876799
struct constexpr_str {
    char const* str;
    std::size_t size;

    // can only construct from a char[] literal
    template <std::size_t N>
    constexpr constexpr_str(char const (&s)[N])
        : str(s)
        , size(N - 1) // not count the trailing nul
    {}
};

class properties {
public:
    int i;
    constexpr_str name;
    std::string name_() { 
        return std::string(name.str);
    }
};

constexpr properties p = { 7, "songnrso" };

template <int i>
int integer() { return i; }

int main() {
    return integer<p.i>();
}

The properties class can be declared as a constexpr and hence the member i can be used as a template parameter.

This approach can be used for the Formula objects, and then I will be able to keep formula properties in one place. The template parameters can be chosen something like this:

case M2.identifier: {
    if (using_avx) {
        if (S.get_julia()) {
            createNewRenderTemplated<M2.identifier, M2.guessing, M2.avx, true>(headless);
        }
...

Another benefit that I expect with this is that it doesn't matter if unnecessary cases are added, such as

            createNewRenderTemplated<M2.identifier, M2.guessing, false, true>(headless);

to use no AVX if M2 doesn't even have an AVX implementation. Then M2.avx is false and both templates will have the same parameters, leading the only one function be compiled and included in the binary. There will be no bloat with unused functions.

daniel2013 commented 3 years ago

Have you considered using one type per formula that contains its functions as well as properties, and using that type as template argument? That way you can keep both the formula and its properties in one place.

A brief example to show the concept:

#include <string>
#include <iostream>

template<typename FormulaT>
class Render {
public:
    void somethingThatCouldGuess() const {
        if constexpr(FormulaT::is_guessable) { // note that `if constexpr` is a C++17 feature
            // make a guess
        }
        else {
            // do something else
        }
    }

    unsigned int calcPoint(unsigned int x, unsigned int y) const {
        std::cout << "Calculating point using formula \"" << FormulaT::name << "\"..." << std::endl;
        return FormulaT::calcPoint(x, y);
    }
};

struct ProcedureM2_Formula { // Separate formula types would be defined as one unique struct (similar to this one) per formula.
    constexpr static int identifier = 4;
    constexpr static bool is_guessable = true;
    constexpr static int inflection_power = 2;
    constexpr static bool is_escape_time = true;
    constexpr static bool escape_radius = true;
    const static std::string name;

    static unsigned int calcPoint(unsigned int x, unsigned int y) {
        // TODO: actual code here
        return x + y;
    }
};

const std::string ProcedureM2_Formula::name = "Mandelbrot power 2"; // has to be initialized out-of-line

// Usage example:
int main() {
    Render<ProcedureM2_Formula> render; // instantiate `Render` with `ProcedureM2_Formula`

    // prints "Calculating point using formula "Mandelbrot power 2"..." and then "3"
    std::cout << render.calcPoint(1, 2) << std::endl;

    return 0;
}
DinkydauSet commented 3 years ago

Thanks for your ideas.

Yes, I have considered using a class for each formula but that was before I knew how to use templates. Knowing what I know now, that may be a good idea. I'll think about it again.

I have also considered placing the calcPoint function of each formula in the class, but there's a problem with that:

calcPoint needs more information than just x and y. For Mandelbrot power 2 the x and y-coordinate on the screen need to be mapped onto a complex number. This is the map function of the FractalParameters class:

    inline double_c map(uint xPos, uint yPos) {
        assert(xPos >= 0); assert(xPos <= get_width());
        assert(yPos >= 0); assert(yPos <= get_height());
        return get_topleftCorner() + xPos * get_pixelWidth() - yPos * get_pixelHeight()*I;
    }

It uses the complex number in the top left corner of the screen and the pixel width and -height. Those values are stored in FractalParameters. There's more than just that. To do what I made the program for (Julia morphings) the Julia morphings need to be applied to the complex number before applying the Mandelbrot formula, and the coordinates of the Julia morphings are also stored in FractalParameters (in the vector inflectionCoords).

So the next idea that I had was: let the Render class apply all those mappings and keep only the Mandelbrot formula as a function in the ProcedureM2_Formula class. Then it would be something like this:

static unsigned int calcPoint(double_c c) {
     int iterationCount = 0;
     double_c z = 0;
     while (z has not escaped) {
         z = pow(z, 2) + c;
         iterationCount++;
     }
     return iterationCount;
  }

That could work, but not as a general solution because not all formulas need the same mapping of x and y onto a complex number. I would still need if-else-if-else... somewhere in the Render class to apply the right mapping for each formula. which made me wonder: what was the point of trying to move the calculation to a class?

I've started to like the calcPoint function as it is, having all the calculations in once place, from x and y coordinates to the final result. It's also very general. I could use it to render something completely different. The only restriction is that the pixels need to be computable independently.

DinkydauSet commented 3 years ago

I made a struct for each escape time formula because then I can save the escape radius in those structs. Now the other formulas don't have an escape radius anymore, as it should be.

template <int procedure_identifier>
struct escapeTimeFormula {
    static constexpr double escapeRadius = 0;
    static double_c apply(double_c z, double_c c) { assert(false); return 0; }; //default implementation that should not be used
};

template <>
struct escapeTimeFormula<M3.identifier> {
    static constexpr double escapeRadius = 2;
    static double_c apply(double_c z, double_c c) {
        return pow(z, 3) + c;
    }
};

...more structs

I renamed everything else related to formulas to procedures. Formula is now called Procedure.

Render doesn't have the guessing parameter in its template anymore because it can take the value from the procedure object beloning to the procedure_identifier:

template <int procedure_identifier, bool use_avx, bool julia>
class Render {
public:
    const uint renderID;
    FractalCanvas& canvas;
    static constexpr Procedure procedure = getProcedureObject(procedure_identifier); //this value is known at compile time

Where guessing is needed, it's available through procedure.guessable

and finally, I use the constexpr values in the Procedure objects as template parameters:

#define procedureRenderCase(procedure_identifier) \
    case procedure_identifier: { \
        constexpr Procedure procedure = getProcedureObject(procedure_identifier); \
        if (using_avx) { \
            if (S.get_julia()) \
                createNewRenderTemplated<procedure_identifier, procedure.hasAvxVersion, procedure.hasJuliaVersion>(headless); \
            else \
                createNewRenderTemplated<procedure_identifier, procedure.hasAvxVersion, false>(headless); \
        } \
        else { \
            if (S.get_julia()) \
                createNewRenderTemplated<procedure_identifier, false, procedure.hasJuliaVersion>(headless); \
            else \
                createNewRenderTemplated<procedure_identifier, false, false>(headless); \
        } \
        break; \
    }

/* createNewRenderTemplated has template:
    template <int procedure_identifier, bool use_avx, bool julia>
*/
void FractalCanvas::createNewRender(bool headless) {
    if(debug) {
        int procedure_identifier = S.get_procedure_identifier();
        assert(procedure_identifier == S.get_procedure().identifier);
        cout << "creating new render with procedure: " << procedure_identifier << " (" << S.get_procedure().name() << ")" << endl;
    }

    switch (S.get_procedure_identifier()) {
        procedureRenderCase(M2.identifier)
        procedureRenderCase(M3.identifier)
        procedureRenderCase(M4.identifier)
        procedureRenderCase(M5.identifier)
        procedureRenderCase(BURNING_SHIP.identifier)
        procedureRenderCase(CHECKERS.identifier)
        procedureRenderCase(TRIPLE_MATCHMAKER.identifier)
        procedureRenderCase(HIGH_POWER.identifier)
        procedureRenderCase(RECURSIVE_FRACTAL.identifier)
        procedureRenderCase(BI.identifier)
        procedureRenderCase(PURE_MORPHINGS.identifier)
    }
}

This causes all combinations of procedure_identifier, use_avx and julia to be generated by the macro. If a procedure doesn't have an avx version or julia version, some of those template parameter combinations are the same. I notice that the size of the exe decreased after doing this so it has the effect that I wanted.

DinkydauSet commented 3 years ago

These are now the steps to add a new procedure:

  1. Add a constexpr Procedure to the list below.
  2. Add it to getProcedureObject.
  3. Add a case to the switch in FractalCanvas::createNewRender.
  4. Define the calculations that should be done when this procedure is used in Render::calcPoint. An AVX implementation should be placed in Render::calcPointVector. Note: hasJuliaVersion and hasAvxVersion should correspond with the real situation. An AVX implementation is not used if hasAvxVersion is false even if there is one. The same goes for julia versions.
  5. To make it available through menu options: 5.1 Create a new value for it in the enum in the MenuOption namespace. 5.2 Use the value in adding the menu in the AddMenus function. 5.3 Handle usage of the menu option in the case WM_COMMAND in MainWndProc.

It's still not really simple but I have no idea how it can be made simpler.

List of all procedure definitions:

//                                      id   guessable inflection-  name                    hasJulia-  hasAvxVersion
//                                                      Power                                 Version
constexpr Procedure NOT_FOUND =         {-1,  false,    0,          "Procedure not found",  false,     false };
constexpr Procedure M2 =                { 4,  true,     2,          "Mandelbrot power 2",   true,      true  };
constexpr Procedure M3 =                { 6,  true,     3,          "Mandelbrot power 3",   true,      false };
constexpr Procedure M4 =                { 7,  true,     4,          "Mandelbrot power 4",   true,      false };
constexpr Procedure M5 =                { 8,  true,     5,          "Mandelbrot power 5",   true,      false };
constexpr Procedure BURNING_SHIP =      { 5,  false,    2,          "Burning ship",         true,      false };
constexpr Procedure CHECKERS =          { 12, true,     2,          "Checkers",             false,     false };
constexpr Procedure TRIPLE_MATCHMAKER = { 11, true,     2,          "Triple Matchmaker",    true,      false };
constexpr Procedure HIGH_POWER =        { 13, true,     2,          "High power Mandelbrot",true,      false };
constexpr Procedure RECURSIVE_FRACTAL = { 15, true,     2,          "Recursive Fractal",    false,     false };
constexpr Procedure BI =                { 16, true,     2,          "Business Intelligence",false,     false };
constexpr Procedure PURE_MORPHINGS =    { 17, true,     2,          "Pure Julia morphings", false,     false };